| 1 | #!/usr/bin/env node |
| 2 | // Real-DOM performance benchmark for the session-switch/history pipeline |
| 3 | // (Phase F). Drives the production build (vite preview) in real Chromium via |
| 4 | // Playwright against the ?mock=bench dev-mock fixtures (see makeMockApp in |
| 5 | // src/lib/bridge.ts): a tool-dense 38-turn session (~3.2k messages) and a |
| 6 | // markdown-heavy 46-turn session (one ~500KiB answer + oversized code block). |
| 7 | // |
| 8 | // Scenario A: cold open — surface first paint and time-to-interactive |
| 9 | // (composer enabled + first transcript slice rendered). |
| 10 | // Scenario B: N alternating switches between the two heaviest sessions — |
| 11 | // per-switch latency, long-task/event-timing stats, INP-ish input probes, and |
| 12 | // a forced-GC retained-heap + cache-budget check against the warmup baseline. |
| 13 | // |
| 14 | // Usage: |
| 15 | // pnpm build # once (the bench reuses dist/; REASONIX_BENCH_BUILD=1 forces a rebuild) |
| 16 | // pnpm test:bench |
| 17 | // |
| 18 | // Gates (env-overridable, defaults = plan values): |
| 19 | // REASONIX_BENCH_FIRST_PAINT_P95_MS (100) |
| 20 | // REASONIX_BENCH_INTERACTIVE_P95_MS (300) |
| 21 | // REASONIX_BENCH_INP_P95_MS (200) |
| 22 | // REASONIX_BENCH_LONGTASK_P95_MS (50) |
| 23 | // REASONIX_BENCH_LONGTASK_MAX_MS (500) |
| 24 | // REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS (3000) |
| 25 | // REASONIX_BENCH_IDLE_CPU_PERCENT (3) |
| 26 | // REASONIX_BENCH_HEAP_GROWTH_MIB (20) |
| 27 | // REASONIX_BENCH_SWITCHES (100) |
| 28 | // REASONIX_BENCH_COLD_RUNS (5) |
| 29 | // REASONIX_BENCH_WARMUP (6) |
| 30 | // REASONIX_BENCH_PORT (4617) |
| 31 | // |
| 32 | // Exit code is 0 when every gate passes, 1 otherwise. Results are written to |
| 33 | // bench/results.json and summarized on stdout. |
| 34 | |
| 35 | import { spawn } from "node:child_process"; |
| 36 | import { existsSync, writeFileSync } from "node:fs"; |
| 37 | import path from "node:path"; |
| 38 | import { fileURLToPath } from "node:url"; |
| 39 | import http from "node:http"; |
| 40 | import { selectSession } from "./app-page-actions.mjs"; |
| 41 | |
| 42 | const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
| 43 | // Keep the browser download inside the repo when the caller did not pin one. |
| 44 | process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers" |
| 45 | ? path.join(frontendDir, ".pw-browsers") |
| 46 | : process.env.PLAYWRIGHT_BROWSERS_PATH; |
| 47 | |
| 48 | const { chromium } = await import("playwright"); |
| 49 | |
| 50 | function numEnv(name, fallback) { |
| 51 | const raw = process.env[name]; |
| 52 | const value = raw === undefined ? NaN : Number(raw); |
| 53 | return Number.isFinite(value) && value > 0 ? value : fallback; |
| 54 | } |
| 55 | |
| 56 | const GATES = { |
| 57 | firstPaintP95Ms: numEnv("REASONIX_BENCH_FIRST_PAINT_P95_MS", 100), |
| 58 | interactiveP95Ms: numEnv("REASONIX_BENCH_INTERACTIVE_P95_MS", 300), |
| 59 | inpP95Ms: numEnv("REASONIX_BENCH_INP_P95_MS", 200), |
| 60 | longTaskP95Ms: numEnv("REASONIX_BENCH_LONGTASK_P95_MS", 50), |
| 61 | longTaskMaxMs: numEnv("REASONIX_BENCH_LONGTASK_MAX_MS", 500), |
| 62 | markdownParseMaxMs: numEnv("REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS", 3000), |
| 63 | idleCpuPercent: numEnv("REASONIX_BENCH_IDLE_CPU_PERCENT", 3), |
| 64 | heapGrowthMiB: numEnv("REASONIX_BENCH_HEAP_GROWTH_MIB", 20), |
| 65 | }; |
| 66 | const SWITCHES = Math.round(numEnv("REASONIX_BENCH_SWITCHES", 100)); |
| 67 | const COLD_RUNS = Math.round(numEnv("REASONIX_BENCH_COLD_RUNS", 5)); |
| 68 | const WARMUP_SWITCHES = Math.round(numEnv("REASONIX_BENCH_WARMUP", 6)); |
| 69 | const PORT = Math.round(numEnv("REASONIX_BENCH_PORT", 4617)); |
| 70 | |
| 71 | // Cache budgets mirrored from transcriptStore.ts defaults (asserted via the |
| 72 | // __reasonixPerf debug hook, which reports the store's own numbers). |
| 73 | const BODY_BUDGET_BYTES = 32 << 20; |
| 74 | const MARKDOWN_BUDGET_BYTES = 16 << 20; |
| 75 | const MAX_RESIDENT_SESSIONS = 3; |
| 76 | |
| 77 | const PAGE_URL = `http://127.0.0.1:${PORT}/?mock=bench&bench=1`; |
| 78 | // Single-surface (workbench) layout: switches are sidebar topic clicks driving |
| 79 | // the ticketed StartTopicActivation flow. Active state is the --active class |
| 80 | // on the topic row; the transcript marker text proves the target session's |
| 81 | // first slice rendered. |
| 82 | const TAB = { |
| 83 | // Markers must be in the STUCK-TO-BOTTOM viewport (the virtual list only |
| 84 | // mounts overscan rows): the tools session ends on its last read_file card, |
| 85 | // the markdown session on the oversized code block (worker-parsed). |
| 86 | markdown: { label: "bench:markdown-46t", marker: "generated migration" }, |
| 87 | tools: { label: "bench:tools-38t", marker: "pkg-41/mod.go" }, |
| 88 | }; |
| 89 | |
| 90 | function percentile(values, p) { |
| 91 | if (values.length === 0) return undefined; |
| 92 | const sorted = [...values].sort((a, b) => a - b); |
| 93 | const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1); |
| 94 | return sorted[Math.max(0, index)]; |
| 95 | } |
| 96 | |
| 97 | function statsOf(values) { |
| 98 | const nums = values.filter((v) => Number.isFinite(v)); |
| 99 | return { |
| 100 | n: nums.length, |
| 101 | p50: percentile(nums, 50), |
| 102 | p95: percentile(nums, 95), |
| 103 | max: nums.length ? Math.max(...nums) : undefined, |
| 104 | }; |
| 105 | } |
| 106 | |
| 107 | async function waitForServer(url, timeoutMs = 30_000) { |
| 108 | const deadline = Date.now() + timeoutMs; |
| 109 | for (;;) { |
| 110 | const ok = await new Promise((resolve) => { |
| 111 | const req = http.get(url, (res) => { |
| 112 | res.resume(); |
| 113 | resolve(res.statusCode !== undefined && res.statusCode < 500); |
| 114 | }); |
| 115 | req.on("error", () => resolve(false)); |
| 116 | req.setTimeout(2000, () => { |
| 117 | req.destroy(); |
| 118 | resolve(false); |
| 119 | }); |
| 120 | }); |
| 121 | if (ok) return; |
| 122 | if (Date.now() > deadline) throw new Error(`preview server did not start at ${url}`); |
| 123 | await new Promise((resolve) => setTimeout(resolve, 200)); |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | async function ensureBuild() { |
| 128 | const distIndex = path.join(frontendDir, "dist", "index.html"); |
| 129 | const force = process.env.REASONIX_BENCH_BUILD === "1"; |
| 130 | if (existsSync(distIndex) && !force) return; |
| 131 | console.log("[bench] building frontend (vite build)…"); |
| 132 | await new Promise((resolve, reject) => { |
| 133 | const child = spawn("pnpm", ["build"], { cwd: frontendDir, stdio: "inherit" }); |
| 134 | child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`pnpm build exited ${code}`)))); |
| 135 | }); |
| 136 | } |
| 137 | |
| 138 | async function startPreview() { |
| 139 | const child = spawn("pnpm", ["exec", "vite", "preview", "--port", String(PORT), "--strictPort", "--host", "127.0.0.1"], { |
| 140 | cwd: frontendDir, |
| 141 | stdio: ["ignore", "pipe", "pipe"], |
| 142 | }); |
| 143 | let stderr = ""; |
| 144 | child.stderr.on("data", (chunk) => { |
| 145 | const message = chunk.toString(); |
| 146 | stderr += message; |
| 147 | process.stderr.write(`[preview] ${message}`); |
| 148 | }); |
| 149 | await new Promise((resolve, reject) => { |
| 150 | let stdout = ""; |
| 151 | const timeout = setTimeout(() => { |
| 152 | cleanup(); |
| 153 | child.kill(); |
| 154 | reject(new Error(`preview server did not announce readiness on port ${PORT}`)); |
| 155 | }, 30_000); |
| 156 | const cleanup = () => { |
| 157 | clearTimeout(timeout); |
| 158 | child.stdout.off("data", onStdout); |
| 159 | child.off("exit", onExit); |
| 160 | }; |
| 161 | const onStdout = (chunk) => { |
| 162 | stdout += chunk.toString(); |
| 163 | if (!/\bLocal:\s+http/.test(stdout)) return; |
| 164 | cleanup(); |
| 165 | resolve(); |
| 166 | }; |
| 167 | const onExit = (code, signal) => { |
| 168 | cleanup(); |
| 169 | const detail = stderr.trim(); |
| 170 | reject(new Error( |
| 171 | `preview server exited before readiness (code ${code ?? "null"}, signal ${signal ?? "none"})${detail ? `: ${detail}` : ""}`, |
| 172 | )); |
| 173 | }; |
| 174 | child.stdout.on("data", onStdout); |
| 175 | child.once("exit", onExit); |
| 176 | }); |
| 177 | await waitForServer(`http://127.0.0.1:${PORT}/`); |
| 178 | return child; |
| 179 | } |
| 180 | |
| 181 | // Installed before any app script: long-task, event-timing (INP-ish) and paint |
| 182 | // collectors. All bounded by the scenario durations (a few minutes). |
| 183 | const COLLECTOR_INIT = () => { |
| 184 | const metrics = { longTasks: [], events: [], paints: [] }; |
| 185 | window.__benchMetrics = metrics; |
| 186 | try { |
| 187 | new PerformanceObserver((list) => { |
| 188 | for (const entry of list.getEntries()) metrics.longTasks.push({ start: entry.startTime, duration: entry.duration }); |
| 189 | }).observe({ type: "longtask", buffered: true }); |
| 190 | } catch { /* longtask unsupported */ } |
| 191 | try { |
| 192 | new PerformanceObserver((list) => { |
| 193 | for (const entry of list.getEntries()) metrics.events.push({ name: entry.name, duration: entry.duration }); |
| 194 | }).observe({ type: "event", durationThreshold: 16, buffered: true }); |
| 195 | } catch { /* event timing unsupported */ } |
| 196 | try { |
| 197 | new PerformanceObserver((list) => { |
| 198 | for (const entry of list.getEntries()) metrics.paints.push({ name: entry.name, start: entry.startTime }); |
| 199 | }).observe({ type: "paint", buffered: true }); |
| 200 | } catch { /* paint timing unsupported */ } |
| 201 | }; |
| 202 | |
| 203 | async function forceGcAndHeap(cdp, page) { |
| 204 | await cdp.send("HeapProfiler.enable").catch(() => {}); |
| 205 | await cdp.send("HeapProfiler.collectGarbage").catch(() => {}); |
| 206 | await page.waitForTimeout(150); |
| 207 | await cdp.send("HeapProfiler.collectGarbage").catch(() => {}); |
| 208 | await page.waitForTimeout(150); |
| 209 | return page.evaluate(() => { |
| 210 | const memory = performance.memory; |
| 211 | return { |
| 212 | usedJSHeapBytes: memory ? memory.usedJSHeapSize : undefined, |
| 213 | totalJSHeapBytes: memory ? memory.totalJSHeapSize : undefined, |
| 214 | domNodes: document.getElementsByTagName("*").length, |
| 215 | }; |
| 216 | }); |
| 217 | } |
| 218 | |
| 219 | async function rendererTaskDuration(cdp) { |
| 220 | await cdp.send("Performance.enable").catch(() => {}); |
| 221 | const response = await cdp.send("Performance.getMetrics"); |
| 222 | return response.metrics.find((metric) => metric.name === "TaskDuration")?.value ?? 0; |
| 223 | } |
| 224 | |
| 225 | const INTERACTIVE_FN = () => { |
| 226 | const input = document.querySelector("textarea.composer__input:not([aria-hidden=true])"); |
| 227 | const inputReady = Boolean(input && !input.disabled); |
| 228 | const rows = document.querySelectorAll(".chat-node").length; |
| 229 | return inputReady && rows > 0; |
| 230 | }; |
| 231 | |
| 232 | async function coldOpenOnce(browser) { |
| 233 | const context = await browser.newContext(); |
| 234 | const page = await context.newPage(); |
| 235 | await page.addInitScript(COLLECTOR_INIT); |
| 236 | await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); |
| 237 | await page.waitForFunction(INTERACTIVE_FN, undefined, { timeout: 30_000, polling: "raf" }); |
| 238 | const result = await page.evaluate(() => { |
| 239 | const paints = (window.__benchMetrics?.paints ?? []); |
| 240 | const fcp = paints.find((p) => p.name === "first-contentful-paint") ?? paints.find((p) => p.name === "first-paint"); |
| 241 | return { firstPaintMs: fcp ? fcp.start : undefined, interactiveMs: performance.now() }; |
| 242 | }); |
| 243 | await context.close(); |
| 244 | return result; |
| 245 | } |
| 246 | |
| 247 | async function waitForSessionVisible(page, tab, timeoutMs = 15_000) { |
| 248 | await page.waitForFunction( |
| 249 | ({ label, marker }) => { |
| 250 | const active = document.querySelector('.project-tree__topic--active .project-tree__topic-label'); |
| 251 | if (!active || !active.textContent?.includes(label)) return false; |
| 252 | const transcript = document.querySelector(".transcript"); |
| 253 | return Boolean(transcript && transcript.textContent?.includes(marker)); |
| 254 | }, |
| 255 | { label: tab.label, marker: tab.marker }, |
| 256 | { timeout: timeoutMs, polling: "raf" }, |
| 257 | ); |
| 258 | } |
| 259 | |
| 260 | async function switchTo(page, tab) { |
| 261 | const startedAt = await page.evaluate(() => performance.now()); |
| 262 | await selectSession(page, tab.label); |
| 263 | await waitForSessionVisible(page, tab); |
| 264 | const settledAt = await page.evaluate(() => performance.now()); |
| 265 | return settledAt - startedAt; |
| 266 | } |
| 267 | |
| 268 | // Let in-flight worker parses finish so the parsed-markdown cache is populated |
| 269 | // and exercised (a fast switch-away cancels pending parses by design). Runs |
| 270 | // AFTER the switch-latency measurement so click→first-render stays pure. |
| 271 | async function settleMarkdownWorker(page, timeoutMs = 10_000) { |
| 272 | await page |
| 273 | .waitForFunction(() => (window.__reasonixPerf?.stats()?.markdownWorker?.pending ?? 0) === 0, undefined, { |
| 274 | timeout: timeoutMs, |
| 275 | polling: 100, |
| 276 | }) |
| 277 | .catch(() => {}); |
| 278 | } |
| 279 | |
| 280 | // Giant history rows retain only a viewport-driven Markdown tail. Stabilize |
| 281 | // worker/React publication around heap/DOM snapshots without forcing cold |
| 282 | // blocks into the DOM; scrolling the older sentinel owns those later pages. |
| 283 | async function settleMarkdownMounts(page, timeoutMs = 10_000) { |
| 284 | try { |
| 285 | await page.waitForFunction(() => ( |
| 286 | [...document.querySelectorAll(".transcript [data-markdown-blocks]")].every((element) => ( |
| 287 | Number(element.getAttribute("data-markdown-blocks")) > 0 |
| 288 | )) |
| 289 | ), undefined, { timeout: timeoutMs, polling: 100 }); |
| 290 | } catch (error) { |
| 291 | const counts = await page.evaluate(() => ( |
| 292 | [...document.querySelectorAll(".transcript [data-markdown-blocks]")].map((element) => ({ |
| 293 | total: element.getAttribute("data-markdown-blocks"), |
| 294 | visible: element.getAttribute("data-markdown-visible-blocks"), |
| 295 | })) |
| 296 | )); |
| 297 | throw new Error(`active Markdown mounts did not settle: ${JSON.stringify(counts)}`, { cause: error }); |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | async function main() { |
| 302 | await ensureBuild(); |
| 303 | const preview = await startPreview(); |
| 304 | const report = { |
| 305 | startedAt: new Date().toISOString(), |
| 306 | url: PAGE_URL, |
| 307 | gates: GATES, |
| 308 | switches: SWITCHES, |
| 309 | coldRuns: COLD_RUNS, |
| 310 | checks: [], |
| 311 | scenarioA: {}, |
| 312 | scenarioB: {}, |
| 313 | }; |
| 314 | const check = (name, value, gate, pass, unit = "ms") => { |
| 315 | report.checks.push({ name, value, gate, pass, unit }); |
| 316 | console.log(` ${pass ? "PASS" : "FAIL"} ${name}: ${typeof value === "number" ? value.toFixed(1) : value}${unit} (gate ${gate}${unit})`); |
| 317 | }; |
| 318 | |
| 319 | const browser = await chromium.launch({ |
| 320 | headless: true, |
| 321 | args: ["--enable-precise-memory-info", "--disable-dev-shm-usage"], |
| 322 | }); |
| 323 | try { |
| 324 | // ── Scenario A: cold open ────────────────────────────────────────────── |
| 325 | console.log(`[bench] scenario A: ${COLD_RUNS} cold opens of the markdown-heavy session…`); |
| 326 | const coldFirstPaint = []; |
| 327 | const coldInteractive = []; |
| 328 | for (let i = 0; i < COLD_RUNS; i += 1) { |
| 329 | const { firstPaintMs, interactiveMs } = await coldOpenOnce(browser); |
| 330 | if (firstPaintMs !== undefined) coldFirstPaint.push(firstPaintMs); |
| 331 | coldInteractive.push(interactiveMs); |
| 332 | } |
| 333 | report.scenarioA = { firstPaintMs: statsOf(coldFirstPaint), interactiveMs: statsOf(coldInteractive) }; |
| 334 | check("A first-paint P95", report.scenarioA.firstPaintMs.p95, GATES.firstPaintP95Ms, report.scenarioA.firstPaintMs.p95 <= GATES.firstPaintP95Ms); |
| 335 | check("A interactive P95", report.scenarioA.interactiveMs.p95, GATES.interactiveP95Ms, report.scenarioA.interactiveMs.p95 <= GATES.interactiveP95Ms); |
| 336 | |
| 337 | // ── Scenario B: alternating heaviest-session switches ────────────────── |
| 338 | console.log(`[bench] scenario B: ${SWITCHES} alternating switches (warmup ${WARMUP_SWITCHES})…`); |
| 339 | const context = await browser.newContext(); |
| 340 | const page = await context.newPage(); |
| 341 | await page.addInitScript(COLLECTOR_INIT); |
| 342 | await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" }); |
| 343 | await page.waitForFunction(INTERACTIVE_FN, undefined, { timeout: 30_000, polling: "raf" }); |
| 344 | await page.waitForFunction(() => Boolean(window.__reasonixPerf), { timeout: 10_000 }); |
| 345 | const cdp = await context.newCDPSession(page); |
| 346 | |
| 347 | // Warmup: fill the LRU with both sessions, then settle back on the |
| 348 | // markdown tab so the baseline and the final reading show the same view. |
| 349 | for (let i = 0; i < WARMUP_SWITCHES; i += 1) { |
| 350 | const target = i % 2 === 0 ? TAB.tools : TAB.markdown; |
| 351 | await switchTo(page, target); |
| 352 | if (target === TAB.markdown) await settleMarkdownWorker(page); |
| 353 | } |
| 354 | await settleMarkdownMounts(page); |
| 355 | const baseline = await forceGcAndHeap(cdp, page); |
| 356 | const baselineStats = await page.evaluate(() => window.__reasonixPerf.stats()); |
| 357 | await page.evaluate(() => { |
| 358 | window.__benchMetrics.longTasks.length = 0; |
| 359 | window.__benchMetrics.events.length = 0; |
| 360 | window.__reasonixPerf.reset(); |
| 361 | }); |
| 362 | |
| 363 | const switchLatencies = []; |
| 364 | for (let i = 0; i < SWITCHES; i += 1) { |
| 365 | // First switch leaves the markdown tab (warmup ended there); even count |
| 366 | // lands back on markdown so the final heap/DOM reading compares like for |
| 367 | // like with the baseline. |
| 368 | const target = i % 2 === 0 ? TAB.tools : TAB.markdown; |
| 369 | switchLatencies.push(await switchTo(page, target)); |
| 370 | if (target === TAB.markdown) await settleMarkdownWorker(page); |
| 371 | if ((i + 1) % 10 === 0) { |
| 372 | // INP-ish probe: real key events against the composer while the |
| 373 | // pipeline is warm; event-timing entries capture the latency. |
| 374 | const composer = page.locator("textarea.composer__input:not([aria-hidden=true])"); |
| 375 | await composer.click(); |
| 376 | await page.keyboard.type("x", { delay: 5 }); |
| 377 | await page.keyboard.press("Backspace"); |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | await settleMarkdownMounts(page); |
| 382 | // Session activation is complete and the worker is drained. Sustained task |
| 383 | // time here is background UI churn; the original regression kept |
| 384 | // committing Markdown blocks after the visible switch had finished. |
| 385 | const idleSampleMs = 3_000; |
| 386 | const idleTaskStart = await rendererTaskDuration(cdp); |
| 387 | await page.waitForTimeout(idleSampleMs); |
| 388 | const idleTaskEnd = await rendererTaskDuration(cdp); |
| 389 | const idleMainThreadPercent = ((idleTaskEnd - idleTaskStart) * 1000 / idleSampleMs) * 100; |
| 390 | |
| 391 | // Freeze interaction metrics before the explicit HeapProfiler GC below. |
| 392 | // The GC is part of retained-heap measurement, not the user switching |
| 393 | // workflow, and can itself create a >50ms task on a loaded test host. |
| 394 | const { finalStats, activations, benchMetrics } = await page.evaluate(() => ({ |
| 395 | finalStats: window.__reasonixPerf.stats(), |
| 396 | activations: window.__reasonixPerf.activations(), |
| 397 | benchMetrics: { |
| 398 | longTasks: window.__benchMetrics.longTasks.map((t) => t.duration), |
| 399 | events: window.__benchMetrics.events.map((e) => e.duration), |
| 400 | }, |
| 401 | })); |
| 402 | const final = await forceGcAndHeap(cdp, page); |
| 403 | await context.close(); |
| 404 | |
| 405 | const switchStats = statsOf(switchLatencies); |
| 406 | const readyStats = statsOf( |
| 407 | activations.filter((a) => a.outcome === "ready" && a.settledAtMs !== undefined).map((a) => a.settledAtMs - a.requestedAtMs), |
| 408 | ); |
| 409 | const longTaskStats = statsOf(benchMetrics.longTasks); |
| 410 | const eventStats = statsOf(benchMetrics.events); |
| 411 | const heapGrowthBytes = (final.usedJSHeapBytes ?? 0) - (baseline.usedJSHeapBytes ?? 0); |
| 412 | const heapGrowthMiB = heapGrowthBytes / (1 << 20); |
| 413 | const cache = finalStats?.transcriptCache ?? {}; |
| 414 | const worker = finalStats?.markdownWorker ?? {}; |
| 415 | |
| 416 | report.scenarioB = { |
| 417 | switchMs: switchStats, |
| 418 | activationReadyMs: readyStats, |
| 419 | longTasksMs: longTaskStats, |
| 420 | inputEventsMs: eventStats, |
| 421 | heap: { |
| 422 | baselineUsedMiB: (baseline.usedJSHeapBytes ?? 0) / (1 << 20), |
| 423 | finalUsedMiB: (final.usedJSHeapBytes ?? 0) / (1 << 20), |
| 424 | growthMiB: heapGrowthMiB, |
| 425 | }, |
| 426 | domNodes: { baseline: baseline.domNodes, final: final.domNodes }, |
| 427 | transcriptCache: cache, |
| 428 | markdownWorker: worker, |
| 429 | idleMainThreadPercent, |
| 430 | baselineCache: baselineStats?.transcriptCache, |
| 431 | }; |
| 432 | |
| 433 | check("B switch latency P95 (click→target session rendered)", switchStats.p95, GATES.interactiveP95Ms, switchStats.p95 <= GATES.interactiveP95Ms); |
| 434 | if (readyStats.n > 0) { |
| 435 | check("B activation ready P95", readyStats.p95, GATES.interactiveP95Ms, readyStats.p95 <= GATES.interactiveP95Ms); |
| 436 | } |
| 437 | check("B input-event P95 (INP-ish)", eventStats.p95 ?? 0, GATES.inpP95Ms, (eventStats.p95 ?? 0) <= GATES.inpP95Ms); |
| 438 | check("B long-task P95", longTaskStats.p95 ?? 0, GATES.longTaskP95Ms, (longTaskStats.p95 ?? 0) <= GATES.longTaskP95Ms); |
| 439 | check("B long-task max", longTaskStats.max ?? 0, GATES.longTaskMaxMs, (longTaskStats.max ?? 0) <= GATES.longTaskMaxMs); |
| 440 | check("B markdown Worker completed parses (minimum)", worker.completed ?? 0, 1, (worker.completed ?? 0) >= 1, ""); |
| 441 | check( |
| 442 | "B markdown Worker max parse", |
| 443 | worker.maxParseMs ?? 0, |
| 444 | GATES.markdownParseMaxMs, |
| 445 | (worker.completed ?? 0) >= 1 && (worker.maxParseMs ?? Infinity) <= GATES.markdownParseMaxMs, |
| 446 | ); |
| 447 | check("B settled renderer task time", idleMainThreadPercent, GATES.idleCpuPercent, idleMainThreadPercent <= GATES.idleCpuPercent, "%"); |
| 448 | check("B retained heap growth", heapGrowthMiB, GATES.heapGrowthMiB, heapGrowthMiB <= GATES.heapGrowthMiB, "MiB"); |
| 449 | check( |
| 450 | "B cache: body bytes within budget", |
| 451 | (cache.bodyBytes ?? 0) / (1 << 20), |
| 452 | BODY_BUDGET_BYTES / (1 << 20), |
| 453 | (cache.bodyBytes ?? 0) <= BODY_BUDGET_BYTES, |
| 454 | "MiB", |
| 455 | ); |
| 456 | check( |
| 457 | "B cache: markdown bytes within budget", |
| 458 | (cache.markdownBytes ?? 0) / (1 << 20), |
| 459 | MARKDOWN_BUDGET_BYTES / (1 << 20), |
| 460 | (cache.markdownBytes ?? 0) <= MARKDOWN_BUDGET_BYTES, |
| 461 | "MiB", |
| 462 | ); |
| 463 | check("B cache: resident sessions", cache.residentSessions ?? 0, MAX_RESIDENT_SESSIONS, (cache.residentSessions ?? 0) <= MAX_RESIDENT_SESSIONS, ""); |
| 464 | const domGrowthPct = baseline.domNodes > 0 ? ((final.domNodes - baseline.domNodes) / baseline.domNodes) * 100 : 0; |
| 465 | check("B DOM node growth vs warmup baseline", domGrowthPct, 10, domGrowthPct <= 10, "%"); |
| 466 | } finally { |
| 467 | await browser.close(); |
| 468 | preview.kill(); |
| 469 | } |
| 470 | |
| 471 | report.finishedAt = new Date().toISOString(); |
| 472 | const failed = report.checks.filter((c) => !c.pass); |
| 473 | report.verdict = failed.length === 0 ? "PASS" : "FAIL"; |
| 474 | writeFileSync(path.join(frontendDir, "bench", "results.json"), JSON.stringify(report, null, 2)); |
| 475 | console.log(`\n[bench] verdict: ${report.verdict} (${failed.length} gate${failed.length === 1 ? "" : "s"} failed) — results in bench/results.json`); |
| 476 | if (failed.length > 0) process.exitCode = 1; |
| 477 | } |
| 478 | |
| 479 | main().catch((err) => { |
| 480 | console.error(`[bench] fatal: ${err instanceof Error ? err.stack ?? err.message : err}`); |
| 481 | process.exitCode = 2; |
| 482 | }); |
| 483 |