#!/usr/bin/env node /** * Measures how long a workspace-card click takes to become VISIBLE. * * The existing e2e spec (tests/e2e/worktree-switch-responsiveness.spec.ts) only * measures the synchronous click task. That stays fast even when the symptom is * present: `markSidebarWorktreeActiveImmediately` mutates the DOM in the click * handler, so the attribute flips in ~1ms. What the user sees is the next * painted FRAME, and that frame is blocked when the async activation path jams * the main thread. So the number that matters is first-paint-after-click. * * Attaches over CDP to an already-running dev app: * * ORCA_DEV_USER_DATA_PATH=... REMOTE_DEBUGGING_PORT=9455 pn dev * node tools/benchmarks/workspace-switch-paint-latency.mjs --port 9455 * * Exits non-zero when the median first-paint exceeds --budget (default 200ms), * which makes it usable directly as a `git bisect run` predicate. */ import { writeFileSync, mkdirSync } from 'node:fs' import path from 'node:path' import { chromium } from '@stablyai/playwright-test' function parseArgs(argv) { const args = { port: 9455, switches: 6, budgetMs: 200, settleMs: 2500, out: null, json: false } for (let i = 0; i < argv.length; i += 1) { const [flag, inlineValue] = argv[i].split('=') const value = inlineValue ?? argv[i + 1] const consume = () => { if (inlineValue === undefined) { i += 1 } } switch (flag) { case '--port': args.port = Number(value) consume() break case '--switches': args.switches = Number(value) consume() break case '--budget': args.budgetMs = Number(value) consume() break case '--settle': args.settleMs = Number(value) consume() break case '--out': args.out = value consume() break case '--json': args.json = true break case '--help': console.log( 'Usage: workspace-switch-paint-latency.mjs [--port 9455] [--switches 6] [--budget 200] [--settle 2500] [--out file.json] [--json]' ) process.exit(0) break default: break } } return args } const args = parseArgs(process.argv.slice(2)) function quantile(sorted, q) { if (sorted.length === 0) { return null } const pos = (sorted.length - 1) * q const lo = Math.floor(pos) const hi = Math.ceil(pos) if (lo === hi) { return sorted[lo] } return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo) } function summarize(values) { const clean = values.filter((v) => typeof v === 'number' && Number.isFinite(v)) if (clean.length === 0) { return null } const sorted = [...clean].sort((a, b) => a - b) return { n: sorted.length, min: +sorted[0].toFixed(1), p50: +quantile(sorted, 0.5).toFixed(1), p90: +quantile(sorted, 0.9).toFixed(1), max: +sorted.at(-1).toFixed(1) } } /** * Installed in the renderer before each click. Records, from the real * pointerdown, every animation frame plus long tasks, so we can tell a jammed * main thread (no frames for ~1s) apart from slow-but-smooth work. */ const PROBE_SOURCE = `(targetId) => { const probe = { t0: null, targetId, frames: [], longTasks: [], attrFlipMs: null, storeCommitMs: null, renderedCommitMs: null, done: false } window.__switchProbe = probe const surfaceOf = (id) => { const row = [...document.querySelectorAll('[data-worktree-id]')].find( (el) => el.dataset.worktreeId === id ) return row ? row.querySelector('[data-worktree-card-surface]') : null } const isActive = (id) => { const s = surfaceOf(id) return Boolean(s && s.getAttribute('data-worktree-card-active')) } const renderedId = () => document .querySelector('[data-rendered-active-worktree-id]') ?.getAttribute('data-rendered-active-worktree-id') ?? null try { const po = new PerformanceObserver((list) => { for (const e of list.getEntries()) { if (probe.t0 === null) continue probe.longTasks.push({ startMs: +(e.startTime - probe.t0).toFixed(1), durationMs: +e.duration.toFixed(1) }) } }) po.observe({ entryTypes: ['longtask'] }) probe.observer = po } catch {} // Why: pointerdown is the earliest thing the user's click produces, so it is // the honest zero point for "time until I saw something happen". const onPointerDown = () => { if (probe.t0 !== null) return probe.t0 = performance.now() const tick = () => { const now = performance.now() - probe.t0 probe.frames.push(+now.toFixed(1)) if (probe.attrFlipMs === null && isActive(probe.targetId)) probe.attrFlipMs = +now.toFixed(1) if (probe.storeCommitMs === null) { const st = window.__store?.getState?.() if (st && st.activeWorktreeId === probe.targetId) probe.storeCommitMs = +now.toFixed(1) } if (probe.renderedCommitMs === null && renderedId() === probe.targetId) { probe.renderedCommitMs = +now.toFixed(1) } if (!probe.done) requestAnimationFrame(tick) } requestAnimationFrame(tick) } window.addEventListener('pointerdown', onPointerDown, { capture: true, once: true }) probe.cleanup = () => { probe.done = true try { probe.observer?.disconnect() } catch {} window.removeEventListener('pointerdown', onPointerDown, { capture: true }) } return true }` /** Press Escape while any full-screen overlay is intercepting pointer events. */ async function dismissOverlays(page) { for (let attempt = 0; attempt < 3; attempt += 1) { const blocked = await page.evaluate(() => [...document.querySelectorAll('body *')].some((el) => { const r = el.getBoundingClientRect() if (r.width < window.innerWidth * 0.9 || r.height < window.innerHeight * 0.9) { return false } const s = getComputedStyle(el) return s.position === 'fixed' && s.pointerEvents !== 'none' && Number(s.zIndex) >= 40 }) ) if (!blocked) { return } await page.keyboard.press('Escape') await page.waitForTimeout(200) } } async function main() { const browser = await chromium.connectOverCDP(`http://127.0.0.1:${args.port}`) const contexts = browser.contexts() const pages = contexts.flatMap((c) => c.pages()) let page = null for (const candidate of pages) { const hasStore = await candidate .evaluate( () => Boolean(window.__store) && Boolean(document.querySelector('[data-worktree-id]')) ) .catch(() => false) if (hasStore) { page = candidate break } } if (!page) { throw new Error( 'No renderer page with window.__store + a mounted sidebar. Is the app past startup, and is the sidebar open?' ) } // Normalize sidebar state so the same cards are reachable across commits. // Why pin the sort: 'recent' reorders the list after every switch, which // would move the card out from under the cursor mid-run. const worktreeIds = await page.evaluate(() => { const state = window.__store.getState() state.setActiveView('terminal') state.setSidebarOpen(true) state.setGroupBy?.('none') state.setSortBy?.('name') state.setShowActiveOnly?.(false) state.setFilterRepoIds?.([]) const viewportH = window.innerHeight // Only cards fully inside the viewport are clickable: the list is // virtualized, so off-screen rows are unmounted or positioned outside. return [...document.querySelectorAll('[data-worktree-id]')] .filter((el) => { const surface = el.querySelector('[data-worktree-card-surface]') if (!surface) { return false } const r = surface.getBoundingClientRect() return r.height > 0 && r.top >= 0 && r.bottom <= viewportH }) .map((el) => el.dataset.worktreeId) .filter((id, i, all) => id && all.indexOf(id) === i) }) if (worktreeIds.length < 2) { throw new Error( `Need >=2 workspace cards visible in the viewport, found ${worktreeIds.length}. Is the sidebar open?` ) } // Alternate between two cards, neither of which is the one already active at // start — clicking the active card short-circuits and measures nothing. const activeAtStart = await page.evaluate(() => window.__store.getState().activeWorktreeId) const pair = worktreeIds.filter((id) => id !== activeAtStart).slice(0, 2) if (pair.length < 2) { throw new Error('Need two visible non-active cards to alternate between') } console.error(`alternating between:\n ${pair[0]}\n ${pair[1]}`) const samples = [] for (let i = 0; i < args.switches; i += 1) { const targetId = pair[i % 2] // Why: page.evaluate() treats a string as an expression to evaluate and // drops the argument, so the probe has to be applied inline. const surface = page .locator(`[data-worktree-id="${targetId}"] [data-worktree-card-surface]`) .first() // Why: a transient popover (`fixed inset-0 z-50`) swallows the click, and // the run would then measure a click that never reached the card. await dismissOverlays(page) await page.evaluate(`(${PROBE_SOURCE})(${JSON.stringify(targetId)})`) // Real CDP input events, not element.click(): the click path has // pointer-drag suppression that a synthetic click would bypass. Going // through the locator also re-resolves position at click time, which // matters because the virtualized list reorders between switches. try { await surface.click({ timeout: 10_000 }) } catch (error) { console.error(`skip switch ${i + 1}: ${String(error).split('\n')[0]}`) continue } await page.waitForTimeout(args.settleMs) const sample = await page.evaluate(() => { const p = window.__switchProbe p.cleanup?.() const frames = p.frames let maxGap = 0 let prev = 0 for (const f of frames) { maxGap = Math.max(maxGap, f - prev) prev = f } return { firstPaintMs: frames.length ? frames[0] : null, maxFrameGapMs: +maxGap.toFixed(1), frameCount: frames.length, attrFlipMs: p.attrFlipMs, storeCommitMs: p.storeCommitMs, renderedCommitMs: p.renderedCommitMs, longTaskCount: p.longTasks.length, longTaskTotalMs: +p.longTasks.reduce((a, t) => a + t.durationMs, 0).toFixed(1), worstLongTaskMs: p.longTasks.reduce((a, t) => Math.max(a, t.durationMs), 0) } }) sample.targetId = targetId // Why: a click that never activated would report a fast "first paint" and // silently make every commit look good during a bisect. if (sample.attrFlipMs === null && sample.renderedCommitMs === null) { throw new Error( `switch ${i + 1} clicked card ${targetId} but it never became active — the measurement is not valid` ) } samples.push(sample) console.error( `switch ${i + 1}/${args.switches} -> firstPaint=${sample.firstPaintMs}ms ` + `maxFrameGap=${sample.maxFrameGapMs}ms attrFlip=${sample.attrFlipMs}ms ` + `rendered=${sample.renderedCommitMs}ms longTasks=${sample.longTaskCount}/${sample.longTaskTotalMs}ms` ) } // Why: the first switch after attach pays one-off lazy work (chunk loads, // cold caches) that a warm app never pays again. Keep it in the raw samples // but exclude it from the verdict so bisect steps compare like with like. const scored = samples.length > 1 ? samples.slice(1) : samples const report = { port: args.port, switches: samples.length, budgetMs: args.budgetMs, firstPaintMs: summarize(scored.map((s) => s.firstPaintMs)), maxFrameGapMs: summarize(scored.map((s) => s.maxFrameGapMs)), attrFlipMs: summarize(scored.map((s) => s.attrFlipMs)), renderedCommitMs: summarize(scored.map((s) => s.renderedCommitMs)), worstLongTaskMs: summarize(scored.map((s) => s.worstLongTaskMs)), longTaskTotalMs: summarize(scored.map((s) => s.longTaskTotalMs)), samples } const medianFirstPaint = report.firstPaintMs?.p50 ?? Infinity const medianFrameGap = report.maxFrameGapMs?.p50 ?? Infinity // The visible symptom is a stall, which shows up either as a late first frame // or as a long gap between frames right after the click. const verdictMs = Math.max(medianFirstPaint, medianFrameGap) report.verdictMs = +verdictMs.toFixed(1) report.pass = verdictMs <= args.budgetMs if (args.out) { mkdirSync(path.dirname(args.out), { recursive: true }) writeFileSync(args.out, JSON.stringify(report, null, 2)) } if (args.json) { console.log(JSON.stringify(report, null, 2)) } else { console.log('') console.log( `first paint after click p50=${report.firstPaintMs?.p50}ms max=${report.firstPaintMs?.max}ms` ) console.log( `max frame gap p50=${report.maxFrameGapMs?.p50}ms max=${report.maxFrameGapMs?.max}ms` ) console.log(`attribute flip (JS) p50=${report.attrFlipMs?.p50}ms`) console.log( `main pane rendered p50=${report.renderedCommitMs?.p50}ms max=${report.renderedCommitMs?.max}ms` ) console.log( `worst long task p50=${report.worstLongTaskMs?.p50}ms max=${report.worstLongTaskMs?.max}ms` ) console.log('') console.log( `${report.pass ? 'PASS' : 'FAIL'} verdict=${report.verdictMs}ms budget=${args.budgetMs}ms` ) } await browser.close() process.exit(report.pass ? 0 : 1) } main().catch((error) => { console.error(error?.stack ?? String(error)) // 125 tells `git bisect run` to skip rather than mark the commit bad. process.exit(125) })