test: add the perf measurement lane (mount latency, longtasks, bundle cost, drag frames)

A measurement harness, not a pass/fail gate (beyond the mount sanity
it shares with the other lanes): an init-script probe samples the
navigationStart → host-attach latency, a longtask collector runs
through the full built-in tab sweep, sidebar-shaped resource entries
report transfer/decoded/duration, and a frame sampler records the
bottom-strip drag's pacing (p50/p95/max) plus the width-leak guard.

Results print as single-line PERF_JSON records so a shell run can tee
and grep them; before/after comparisons land in
docs/plans/2026-08-31-perf-optimization.md. Baseline (main @ d70db8f,
3 runs): mount ≈ 377ms, 2 longtasks ≈ 161ms, drag p95 = 10ms,
width leak = 504px.
This commit is contained in:
Menghuan1918
2026-09-01 15:58:44 +08:00
parent 659c2a20e2
commit e2699a099e
+275
View File
@@ -0,0 +1,275 @@
/**
* Perf lane: a MEASUREMENT harness, not a pass/fail gate (beyond the mount
* sanity it shares with the other lanes).
*
* It exists to put NUMBERS on the sidebar's client-side cost in a real
* mounted `dsh web` (the same scratch-profile boot as scripts/e2e-mount.sh):
*
* 1. mount latency — navigationStart → `[data-dsh-better-sidebar]`
* attached, sampled by an init-script probe (rAF loop, so it catches
* the attach even before Playwright's first poll);
* 2. longtasks — every >50ms main-thread task from first paint
* through the full built-in tab sweep (the heaviest mount surface);
* 3. bundle cost — per-resource transferSize/decodedBodySize/duration
* for everything sidebar-shaped the page fetched;
* 4. bottom-drag frames — rAF frame-interval p95/max while dragging the
* bottom strip (the layout-push hot path), plus the width-leak guard
* from the drag lane (a closed right panel must push 0).
*
* Results print as single-line `PERF_JSON` records (plus a `PERF_SUMMARY`
* aggregate) so a shell run can `tee` the log and grep them out; the
* before/after comparison lives in docs/plans/2026-08-31-perf-optimization.md.
* Run it 3× and take the median of each metric — single-run numbers on a
* developer machine are noise, not baselines.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc } from './host'
/** This lane's own workspace (lanes run serially against one server — never
* share seed paths with mount/drag). */
const WORKSPACE_PATH = process.env.DSH_E2E_PERF_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-perf-workspace')
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
const BUILTIN_TABS = ['Files', 'Changes', 'Tasks', 'Side Chat (beta)', 'Terminal', 'Browser']
let api: APIRequestContext
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'perf lane\n')
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
}
test.beforeAll(async () => {
api = await createHostApi()
await seedSession()
})
test.afterAll(async () => {
await api?.dispose()
})
/** The longtask collector + mount-attach probe, injected before ANY page
* script runs (longtasks observed late are gone forever). */
const PERF_PROBE = `(() => {
const perf = { mountAt: null, longtasks: [] }
window.__perfProbe = perf
new PerformanceObserver(list => {
for (const entry of list.getEntries()) perf.longtasks.push({ start: entry.startTime, duration: entry.duration })
}).observe({ entryTypes: ['longtask'] })
const check = () => {
if (document.querySelector('[data-dsh-better-sidebar]') !== null) { perf.mountAt = performance.now(); return }
requestAnimationFrame(check)
}
requestAnimationFrame(check)
})()`
function percentile(values: number[], p: number): number {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))
return sorted[index]!
}
interface ResourceStat {
name: string
transferSize: number
decodedBodySize: number
duration: number
}
/** Dismiss whatever onboarding takeover is present (same dance as the other
* lanes) so the pointer can reach the sidebar's controls. */
async function dismissOnboarding(page: Page): Promise<void> {
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e-perf] no onboarding takeover appeared; proceeding')
}
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try {
await button.click({ timeout: 4_000 })
dismissed = true
await page.waitForTimeout(1_000)
} catch {
// Masked by a takeover stacked above; retry in the next round.
}
}
if (!dismissed) break
}
}
test('measure: mount latency, longtasks and bundle cost through a full tab sweep', async ({ page }) => {
await page.addInitScript(PERF_PROBE)
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
const mountLatency = await page.evaluate(() =>
(window as unknown as { __perfProbe: { mountAt: number | null } }).__perfProbe.mountAt)
expect(mountLatency, 'the mount probe must have caught the attach').not.toBeNull()
await dismissOnboarding(page)
// Open the panel, then sweep every built-in tab through the "+" menu — the
// same crash-sweep surface as mount.e2e.ts, reused here as the workload.
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton).toHaveCount(1)
await expandButton.click()
await expect
.poll(async () => {
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return value !== '' && value !== '0px'
}, { timeout: 90_000 })
.toBe(true)
const newTabButton = sidebar.getByRole('button', { name: 'New tab' }).first()
for (const title of BUILTIN_TABS) {
await newTabButton.click()
const item = page.getByRole('menuitem', { name: title }).first()
await expect(item, `built-in tab "${title}" must be offered by the + menu`).toHaveCount(1)
await item.click()
await page.waitForTimeout(1_500)
}
// Let the last tab settle (lazy chunks + first render) before reading.
await page.waitForTimeout(2_000)
const probe = await page.evaluate(() => (window as unknown as { __perfProbe: { mountAt: number | null; longtasks: Array<{ start: number; duration: number }> } }).__perfProbe)
const resources = await page.evaluate(() =>
performance.getEntriesByType('resource')
.filter(entry => /sidebar/.test(entry.name))
.map(entry => ({
name: entry.name.replace(/^https?:\/\/[^/]+/, ''),
transferSize: entry.transferSize,
decodedBodySize: entry.decodedBodySize,
duration: entry.duration,
})) as ResourceStat[],
)
const longtasks = probe.longtasks ?? []
const record = {
metric: 'mount-sweep',
mountLatencyMs: mountLatency,
longtaskCount: longtasks.length,
longtaskTotalMs: Math.round(longtasks.reduce((sum, task) => sum + task.duration, 0)),
longtaskMaxMs: longtasks.reduce((max, task) => Math.max(max, task.duration), 0),
resourceCount: resources.length,
resourceTransferTotal: resources.reduce((sum, r) => sum + r.transferSize, 0),
resourceDecodedTotal: resources.reduce((sum, r) => sum + r.decodedBodySize, 0),
resources,
}
console.log(`PERF_JSON ${JSON.stringify(record)}`)
console.log(
`PERF_SUMMARY mount=${Math.round(mountLatency!)}ms longtasks=${record.longtaskCount}/${record.longtaskTotalMs}ms `
+ `resources=${record.resourceCount}/${(record.resourceTransferTotal / 1024).toFixed(0)}KiB`,
)
})
test('measure: bottom-strip drag frame pacing', async ({ page }) => {
await page.addInitScript(PERF_PROBE)
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
// The right panel stays CLOSED on purpose: the bottom drag must not push
// the host layout in that pose (the width-leak regression the drag lane
// locks; recorded here as a number-adjacent guard for the perf story).
const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
await expect(bottomExpand).toHaveCount(1)
await bottomExpand.click()
await expect
.poll(async () => {
const value = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0)
return value
}, { timeout: 90_000 })
.toBeGreaterThan(0)
// Frame-pacing sampler: rAF timestamps during the drag only.
await page.evaluate(() => {
const frames: number[] = []
const pushWidths: number[] = []
const loop = (): void => {
frames.push(performance.now())
const width = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
pushWidths.push(Number.isNaN(width) ? 0 : width)
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
;(window as unknown as { __dragFrames: number[]; __dragPushWidths: number[] }).__dragFrames = frames
;(window as unknown as { __dragPushWidths: number[] }).__dragPushWidths = pushWidths
})
// The panel slides down into place on expand: read the strip only after it
// settles at innerHeight - the pushed height (a mid-animation box makes the
// pointerdown land off-strip and the drag never starts).
await expect
.poll(async () => {
const probe = await page.evaluate(() => {
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
if (bottom === null) return null
const strip = [...bottom.querySelectorAll<HTMLElement>('*')].find(el => getComputedStyle(el).cursor === 'row-resize')
if (strip === undefined) return null
const r = strip.getBoundingClientRect()
const heightVar = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')) || 0
return Math.abs((r.y + r.height / 2) - (window.innerHeight - heightVar))
})
return probe === null ? Number.NaN : probe
}, { timeout: 30_000 })
.toBeLessThanOrEqual(8)
const stripPoint = await page.evaluate(() => {
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
if (bottom === null) return null
const strip = [...bottom.querySelectorAll<HTMLElement>('*')].find(el => getComputedStyle(el).cursor === 'row-resize')
if (strip === undefined) return null
const r = strip.getBoundingClientRect()
return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
})
expect(stripPoint, 'the bottom drag strip must be present').not.toBeNull()
const t0 = await page.evaluate(() => performance.now())
await page.mouse.move(stripPoint!.x, stripPoint!.y)
await page.mouse.down()
for (let i = 1; i <= 12; i++) {
await page.mouse.move(stripPoint!.x, stripPoint!.y - i * 8, { steps: 2 })
await page.waitForTimeout(40)
}
await page.mouse.up()
const t1 = await page.evaluate(() => performance.now())
await page.waitForTimeout(300)
const { frames, widths } = await page.evaluate(() => ({
frames: (window as unknown as { __dragFrames: number[] }).__dragFrames,
widths: (window as unknown as { __dragPushWidths: number[] }).__dragPushWidths,
}))
const dragFrames = frames.filter(t => t >= t0 && t <= t1)
const intervals: number[] = []
for (let i = 1; i < dragFrames.length; i++) intervals.push(dragFrames[i]! - dragFrames[i - 1]!)
const dragWidths = widths.slice(Math.max(0, frames.indexOf(dragFrames[0] ?? frames[0]!)))
const record = {
metric: 'bottom-drag',
frames: dragFrames.length,
intervalMedianMs: Math.round(percentile(intervals, 50)),
intervalP95Ms: Math.round(percentile(intervals, 95)),
intervalMaxMs: Math.round(Math.max(0, ...intervals)),
pushWidthLeakMax: Math.max(0, ...dragWidths),
}
console.log(`PERF_JSON ${JSON.stringify(record)}`)
console.log(
`PERF_SUMMARY drag-frames=${record.frames} p50=${record.intervalMedianMs}ms p95=${record.intervalP95Ms}ms `
+ `max=${record.intervalMaxMs}ms widthLeak=${record.pushWidthLeakMax}px`,
)
expect(record.pushWidthLeakMax, 'the closed right panel must push 0 width during the bottom drag').toBe(0)
})