Skip to content

Commit c6083a4

Browse files
authored
test(app): add manual performance diagnostics (anomalyco#32937)
1 parent 10ec856 commit c6083a4

26 files changed

Lines changed: 2656 additions & 3 deletions

packages/app/AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## Priorities
2+
3+
- Prioritise, in this order: stability, simplicity, performance.
4+
- Before changing session or timeline code, record a production benchmark baseline and compare it after the change.
5+
16
## Debugging
27

38
- NEVER try to restart the app, or the server process, EVER.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
- Prioritize stability, then simplicity, then measurement overhead.
2+
- Use Playwright for scenario control, isolation, and completion checks.
3+
- Use Chrome Performance traces for generic browser profiling.
4+
- Use Electron `contentTracing` for packaged multi-process profiling.
5+
- Keep custom probes only for product-specific measurements.
6+
- Do not duplicate measurements across the harness, probes, and traces.
7+
- Run benchmarks serially to avoid cross-test contention.
8+
- Run benchmarks against production builds.
9+
- Keep detailed profiling opt-in when it changes workload behavior.
10+
- Preserve raw diagnostic data or use lossless representations.
11+
- Do not enforce machine-dependent performance thresholds.
12+
- Assert scenario completion and metric collection only.
13+
- Keep normal test discovery free of manual benchmarks.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Manual app performance suite
2+
3+
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
4+
5+
Run the suite explicitly from `packages/app`:
6+
7+
```sh
8+
bun run test:bench
9+
```
10+
11+
PowerShell:
12+
13+
```powershell
14+
$env:PLAYWRIGHT_WORKERS = "1"
15+
bun run test:bench
16+
```
17+
18+
The suite contains:
19+
20+
- cold and hot session-tab timing
21+
- cached session repaint and mutation tracing
22+
- streaming timeline throughput, RAF-gap, long-task, geometry, and remount diagnostics
23+
24+
All benchmarks import the shared `benchmark` fixture. Pages created through Playwright's `page` fixture automatically capture main-frame navigation history and emit a Chrome trace when `OPENCODE_PERFORMANCE_TRACE_DIR` is set. Benchmarks that need isolated browser contexts use `withBenchmarkPage`, which owns the context and the same diagnostics lifecycle.
25+
26+
New benchmarks should look like normal Playwright tests:
27+
28+
```ts
29+
import { benchmark, expect } from "../benchmark"
30+
31+
benchmark("measures one interaction", async ({ page, report }) => {
32+
// Only scenario-specific setup and interaction belong here.
33+
report({ durationMs: 42 })
34+
})
35+
```
36+
37+
The fixture requires every benchmark to call `report()`, automatically names and closes traces, captures navigation history, attaches that history when a test fails, and emits metrics as a consistent `BENCHMARK` JSON line.
38+
39+
```text
40+
BENCHMARK {"name":"...","context":{"project":"chromium","platform":"darwin"},"metrics":{...}}
41+
```
42+
43+
Every observed page also emits `BENCHMARK_PAGE` with the same run ID, navigation history, and optional trace path before the final status-bearing `BENCHMARK` record. Chrome traces are browser-wide page-lifetime diagnostics; scenario metrics use narrower explicitly named observation windows.
44+
45+
This follows the stack's own guidance: [Electron recommends repeated Chrome DevTools and Chrome Tracing measurement](https://www.electronjs.org/docs/latest/tutorial/performance), [Chrome DevTools recommends Performance recordings for runtime work](https://developer.chrome.com/docs/devtools/performance), and [Playwright uses traces for test debugging rather than renderer profiling](https://playwright.dev/docs/trace-viewer).
46+
47+
These Playwright benchmarks profile the shared app renderer in Chromium. A future packaged Electron benchmark that needs main-process and multi-process attribution should use Electron's official [`contentTracing`](https://www.electronjs.org/docs/latest/api/content-tracing/) API rather than extending this renderer harness with bespoke process instrumentation.
48+
49+
CPU and high-volume visual profiling are disabled by default. Set `TIMELINE_CPU_PROFILE=1` to enable both, or additionally set `TIMELINE_VISUAL_PROFILE=0` for CPU-only profiling.
50+
51+
The streaming scenario's 30x CPU throttle is a deterministic stress profile, not a simulated end-user device.
52+
53+
Benchmarks do not assert machine-dependent performance budgets. Streaming processes 160 deltas by default and reports renderer-observed completion time, throughput, RAF callback-gap distributions, frame-budget equivalents, and long tasks through final geometry settlement. Delta count and delivery batch are included in result context when overridden. These are main-thread callback diagnostics, not compositor presentation or dropped-frame measurements. Visual-only and geometry metrics are `null` when their probes are disabled. Tab metrics describe sampled DOM observations. Assertions verify scenario and metric collection completion. Repeated repaint states are run-length grouped, but every original observation timestamp is retained alongside raw mutation batches and layout shifts.
54+
55+
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
56+
57+
## Chrome traces
58+
59+
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
60+
61+
```sh
62+
OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-performance-traces \
63+
bunx playwright test --config e2e/performance/playwright.config.ts \
64+
timeline/session-tab-switch-benchmark.spec.ts
65+
```
66+
67+
The emitted JSON is a standard Chrome trace and can be loaded directly into the Chrome DevTools Performance panel. `devtools-tracing` can optionally inspect it from the command line without adding package scripts or dependencies:
68+
69+
Trace capture mirrors [Puppeteer's official tracing defaults and lifecycle](https://pptr.dev/api/puppeteer.tracing), using Chrome's `ReturnAsStream` transfer mode and failing when Chromium reports trace data loss.
70+
71+
```sh
72+
bunx devtools-tracing stats <trace-path-from-BENCHMARK_PAGE>
73+
```
74+
75+
INP analysis requires a trace with a supported navigation/interaction insight. Selector statistics require a trace captured with `OPENCODE_PERFORMANCE_SELECTOR_TRACE=1`.
76+
77+
`e2e/performance/playwright.uncapped.config.ts` disables Chromium frame-rate limiting for explicit uncapped diagnostics. Native product benchmarks should use the default Playwright configuration.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { expect, test as base, type Browser, type Page, type TestInfo } from "@playwright/test"
2+
import { startChromeTrace } from "./chrome-trace"
3+
4+
type BenchmarkFixtures = {
5+
report: (metrics: Record<string, unknown>, context?: Record<string, unknown>) => void
6+
reportState: { payload?: { metrics: Record<string, unknown>; context: Record<string, unknown> } }
7+
benchmarkResult: void
8+
}
9+
10+
export type PerformancePageDiagnostics = {
11+
navigations: string[]
12+
stop: () => Promise<string | undefined>
13+
}
14+
15+
const pages = new WeakMap<Page, PerformancePageDiagnostics>()
16+
17+
export const benchmark = base.extend<BenchmarkFixtures>({
18+
reportState: async ({}, use) => use({}),
19+
report: async ({ reportState }, use) => {
20+
await use((metrics, context = {}) => {
21+
if (reportState.payload) throw new Error("Benchmark reported metrics more than once")
22+
reportState.payload = { metrics, context }
23+
})
24+
},
25+
benchmarkResult: [
26+
async ({ reportState }, use, testInfo) => {
27+
await use()
28+
const missing = !reportState.payload
29+
console.log(
30+
`BENCHMARK ${JSON.stringify({
31+
schemaVersion: 2,
32+
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
33+
name: benchmarkName(testInfo),
34+
status: missing ? "failed" : testInfo.status,
35+
expectedStatus: testInfo.expectedStatus,
36+
retry: testInfo.retry,
37+
repeatEachIndex: testInfo.repeatEachIndex,
38+
context: {
39+
project: testInfo.project.name,
40+
platform: process.platform,
41+
...reportState.payload?.context,
42+
},
43+
metrics: reportState.payload?.metrics ?? null,
44+
error: missing ? "Benchmark did not report metrics" : undefined,
45+
})}`,
46+
)
47+
if (missing && testInfo.status === testInfo.expectedStatus)
48+
throw new Error(`Benchmark did not report metrics: ${benchmarkName(testInfo)}`)
49+
},
50+
{ auto: true },
51+
],
52+
page: async ({ page }, use, testInfo) => {
53+
const name = benchmarkName(testInfo)
54+
const diagnostics = await observePerformancePage(page, name)
55+
try {
56+
await use(page)
57+
} finally {
58+
try {
59+
await reportPerformancePage(name, diagnostics, testInfo)
60+
} finally {
61+
if (testInfo.status !== testInfo.expectedStatus) {
62+
await testInfo.attach("performance-navigations", {
63+
body: JSON.stringify(diagnostics.navigations, null, 2),
64+
contentType: "application/json",
65+
})
66+
}
67+
}
68+
}
69+
},
70+
})
71+
72+
function benchmarkName(testInfo: TestInfo) {
73+
return testInfo.titlePath.slice(1).join(" > ")
74+
}
75+
76+
export { expect }
77+
78+
async function observePerformancePage(page: Page, name: string) {
79+
const navigations: string[] = []
80+
const onNavigation = (frame: ReturnType<Page["mainFrame"]>) => {
81+
if (frame === page.mainFrame()) navigations.push(frame.url())
82+
}
83+
page.on("framenavigated", onNavigation)
84+
const stopTrace = await startChromeTrace(page, name).catch((error) => {
85+
page.off("framenavigated", onNavigation)
86+
throw error
87+
})
88+
let stopping: Promise<string | undefined> | undefined
89+
const diagnostics: PerformancePageDiagnostics = {
90+
navigations,
91+
stop() {
92+
page.off("framenavigated", onNavigation)
93+
return (stopping ??= stopTrace?.() ?? Promise.resolve(undefined))
94+
},
95+
}
96+
pages.set(page, diagnostics)
97+
return diagnostics
98+
}
99+
100+
export async function withBenchmarkPage<T>(
101+
browser: Browser,
102+
name: string,
103+
run: (page: Page) => Promise<T>,
104+
testInfo?: TestInfo,
105+
) {
106+
const context = await browser.newContext()
107+
try {
108+
const page = await context.newPage()
109+
const diagnostics = await observePerformancePage(page, name)
110+
try {
111+
return await run(page)
112+
} finally {
113+
await reportPerformancePage(name, diagnostics, testInfo)
114+
}
115+
} finally {
116+
await context.close()
117+
}
118+
}
119+
120+
async function reportPerformancePage(name: string, diagnostics: PerformancePageDiagnostics, testInfo?: TestInfo) {
121+
const trace = await diagnostics.stop()
122+
console.log(
123+
`BENCHMARK_PAGE ${JSON.stringify({
124+
schemaVersion: 2,
125+
runID: process.env.OPENCODE_PERFORMANCE_RUN_ID,
126+
name,
127+
test: testInfo ? benchmarkName(testInfo) : undefined,
128+
retry: testInfo?.retry,
129+
repeatEachIndex: testInfo?.repeatEachIndex,
130+
context: {
131+
platform: process.platform,
132+
trace,
133+
selectorTrace: process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1",
134+
},
135+
navigations: diagnostics.navigations,
136+
})}`,
137+
)
138+
}
139+
140+
export function benchmarkDiagnostics(page: Page) {
141+
const diagnostics = pages.get(page)
142+
if (!diagnostics) throw new Error("Performance diagnostics are not installed for this page")
143+
return diagnostics
144+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { CDPSession, Page } from "@playwright/test"
2+
import path from "node:path"
3+
import { mkdir, open, rename } from "node:fs/promises"
4+
import { Buffer } from "node:buffer"
5+
import { createHash, randomUUID } from "node:crypto"
6+
7+
const categories = [
8+
"-*",
9+
"devtools.timeline",
10+
"v8.execute",
11+
"disabled-by-default-devtools.timeline",
12+
"disabled-by-default-devtools.timeline.frame",
13+
"toplevel",
14+
"blink.console",
15+
"blink.user_timing",
16+
"latencyInfo",
17+
"disabled-by-default-devtools.timeline.stack",
18+
"disabled-by-default-v8.cpu_profiler",
19+
]
20+
21+
export async function startChromeTrace(page: Page, name: string) {
22+
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
23+
if (!directory) return
24+
25+
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
26+
const file = await prepareChromeTrace(directory, name, selectors)
27+
const session = await page.context().newCDPSession(page)
28+
try {
29+
await session.send("Tracing.start", {
30+
transferMode: "ReturnAsStream",
31+
traceConfig: {
32+
excludedCategories: categories
33+
.filter((category) => category.startsWith("-"))
34+
.map((category) => category.slice(1)),
35+
includedCategories: [
36+
...categories.filter((category) => !category.startsWith("-")),
37+
...(selectors
38+
? ["disabled-by-default-blink.debug", "disabled-by-default-devtools.timeline.invalidationTracking"]
39+
: []),
40+
],
41+
},
42+
})
43+
} catch (error) {
44+
await Promise.allSettled([session.detach()])
45+
throw error
46+
}
47+
let stopping: Promise<string> | undefined
48+
49+
return () =>
50+
(stopping ??= (async () => {
51+
try {
52+
const complete = new Promise<{ stream?: string; dataLossOccurred: boolean }>((resolve) =>
53+
session.once("Tracing.tracingComplete", resolve),
54+
)
55+
await session.send("Tracing.end")
56+
const result = await complete
57+
if (!result.stream) throw new Error(`Chrome trace stream missing: ${file}`)
58+
const partial = `${file}.partial`
59+
await writeProtocolStream(session, result.stream, partial)
60+
if (result.dataLossOccurred) throw new Error(`Chrome trace lost data; partial capture retained: ${partial}`)
61+
await rename(partial, file)
62+
return file
63+
} finally {
64+
await Promise.allSettled([session.detach()])
65+
}
66+
})())
67+
}
68+
69+
export async function prepareChromeTrace(
70+
directory: string,
71+
name: string,
72+
selectors: boolean,
73+
nonce = randomUUID().slice(0, 8),
74+
) {
75+
await mkdir(directory, { recursive: true })
76+
const run = process.env.OPENCODE_PERFORMANCE_RUN_ID ?? "manual"
77+
const hash = createHash("sha256").update(name).digest("hex").slice(0, 8)
78+
return path.join(
79+
directory,
80+
`${run}-${name.replace(/[^a-zA-Z0-9_-]/g, "-")}-${hash}-${nonce}${selectors ? "-selectors" : ""}.json`,
81+
)
82+
}
83+
84+
async function writeProtocolStream(session: CDPSession, handle: string, file: string) {
85+
const output = await open(file, "wx")
86+
try {
87+
while (true) {
88+
const chunk = await session.send("IO.read", { handle })
89+
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
90+
if (chunk.eof) break
91+
}
92+
} finally {
93+
await Promise.allSettled([output.close(), session.send("IO.close", { handle })])
94+
}
95+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import config from "../../playwright.config"
2+
3+
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000)
4+
process.env.PLAYWRIGHT_SERVER_PORT = String(port)
5+
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
6+
7+
export default {
8+
...config,
9+
testDir: ".",
10+
testIgnore: "unit/**",
11+
outputDir: "../test-results/performance",
12+
fullyParallel: false,
13+
workers: 1,
14+
reporter: [["html", { outputFolder: "../playwright-report/performance", open: "never" }], ["line"]],
15+
webServer: {
16+
...config.webServer,
17+
command: `bun run build && bun run serve -- --host 0.0.0.0 --port ${port} --strictPort`,
18+
reuseExistingServer: false,
19+
},
20+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import config from "./playwright.config"
2+
3+
export default {
4+
...config,
5+
outputDir: "../test-results/performance-uncapped",
6+
reporter: [["html", { outputFolder: "../playwright-report/performance-uncapped", open: "never" }], ["line"]],
7+
use: {
8+
...config.use,
9+
launchOptions: {
10+
args: ["--disable-frame-rate-limit", "--disable-gpu-vsync"],
11+
},
12+
},
13+
}

0 commit comments

Comments
 (0)