From 899bb254a1a6a8d094fc1526bd5601caa23cc5a1 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 23 May 2026 14:32:15 +0800 Subject: [PATCH 1/2] test: keep perf CLS setup scrolls outside measured windows --- .../app/e2e/perf/perf-probe-window.unit.ts | 75 ++++++++++++++++ packages/app/e2e/perf/perf-probe.spec.ts | 85 ++++++++++++++----- 2 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 packages/app/e2e/perf/perf-probe-window.unit.ts diff --git a/packages/app/e2e/perf/perf-probe-window.unit.ts b/packages/app/e2e/perf/perf-probe-window.unit.ts new file mode 100644 index 000000000..de7627e22 --- /dev/null +++ b/packages/app/e2e/perf/perf-probe-window.unit.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" + +const specPath = new URL("./perf-probe.spec.ts", import.meta.url) + +async function readPerfProbeSpec() { + return fs.readFile(specPath, "utf8") +} + +function extractMeasuredWindows(source: string) { + const resetPattern = "await resetPerfProbe(page)" + const snapshotPattern = "snapshotPerfProbe(page)" + const windows: string[] = [] + let cursor = 0 + + while (true) { + const resetIndex = source.indexOf(resetPattern, cursor) + if (resetIndex === -1) break + const snapshotIndex = source.indexOf(snapshotPattern, resetIndex) + if (snapshotIndex === -1) break + windows.push(source.slice(resetIndex, snapshotIndex + snapshotPattern.length)) + cursor = snapshotIndex + snapshotPattern.length + } + + return windows +} + +function extractFunction(source: string, name: string) { + const signatureIndex = source.indexOf(`function ${name}`) + expect(signatureIndex).toBeGreaterThanOrEqual(0) + + const bodyStart = source.indexOf("{", signatureIndex) + expect(bodyStart).toBeGreaterThanOrEqual(0) + + let depth = 0 + for (let index = bodyStart; index < source.length; index += 1) { + const char = source[index] + if (char === "{") depth += 1 + if (char === "}") { + depth -= 1 + if (depth === 0) return source.slice(signatureIndex, index + 1) + } + } + + throw new Error(`Could not parse function ${name}`) +} + +describe("perf probe measured windows", () => { + test("keep setup-only timeline scroll jumps outside measured windows", async () => { + const source = await readPerfProbeSpec() + const violations = extractMeasuredWindows(source).filter((window) => + window.includes("setTimelineScrollTopForSetup(page"), + ) + + expect(violations).toEqual([]) + }) + + test("reject setup-only timeline scroll jumps while a measured window is active", async () => { + const source = await readPerfProbeSpec() + const measurePerfWindow = extractFunction(source, "measurePerfWindow") + const setupScroll = extractFunction(source, "setTimelineScrollTopForSetup") + + expect(measurePerfWindow).toContain("measuredPerfWindowDepth += 1") + expect(measurePerfWindow).toContain("return await snapshotPerfProbe(page)") + expect(measurePerfWindow).toContain("measuredPerfWindowDepth -= 1") + expect(setupScroll).toContain("if (measuredPerfWindowDepth > 0)") + }) + + test("name direct timeline scroll helper as setup-only", async () => { + const source = await readPerfProbeSpec() + + expect(source).not.toContain("function scrollTimelineTo") + expect(source).toContain("function setTimelineScrollTopForSetup") + }) +}) diff --git a/packages/app/e2e/perf/perf-probe.spec.ts b/packages/app/e2e/perf/perf-probe.spec.ts index a38d3993d..b7bd62c1c 100644 --- a/packages/app/e2e/perf/perf-probe.spec.ts +++ b/packages/app/e2e/perf/perf-probe.spec.ts @@ -66,6 +66,7 @@ const longScrollMinimumMovingSamples = 16 const longScrollMinimumDistinctScrollTops = 12 const scenarioResults: ReturnType[] = [] +let measuredPerfWindowDepth = 0 type PerfSdk = ReturnType type PerfProject = { @@ -146,6 +147,26 @@ async function cooldownAfterRun(page: Parameters[0]) { await page.waitForTimeout(250) } +async function measurePerfWindow( + page: Parameters[0], + action: () => Promise, +) { + await settleFrames(page, 2) + await resetPerfProbe(page) + measuredPerfWindowDepth += 1 + try { + await action() + return await snapshotPerfProbe(page) + } finally { + measuredPerfWindowDepth -= 1 + } +} + +async function settlePerfSetup(page: Parameters[0]) { + await settleFrames(page, 12) + await page.waitForTimeout(250) +} + async function ensureTerminalClosed(page: Parameters[0]) { const terminal = page.locator(terminalSelector) const visible = await terminal.isVisible().catch(() => false) @@ -210,7 +231,7 @@ async function revealCachedSessionMessages(page: Parameters false)) @@ -252,7 +273,10 @@ async function readTimelineDriverEvent() { } } -async function scrollTimelineTo(page: Parameters[0], top: number) { +async function setTimelineScrollTopForSetup(page: Parameters[0], top: number) { + if (measuredPerfWindowDepth > 0) { + throw new Error("setTimelineScrollTopForSetup must run outside perf measured windows") + } const found = await page.evaluate( ({ top, scrollViewportSelector, turnListSelector }) => { const list = document.querySelector(turnListSelector) @@ -282,7 +306,16 @@ async function markTimelineWheelIntent(page: Parameters[0]) { - const box = await page.locator(scrollViewportSelector).first().boundingBox() + const box = await page.evaluate( + ({ scrollViewportSelector, turnListSelector }) => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return undefined + const rect = viewport.getBoundingClientRect() + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height } + }, + { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) expect(box).toBeTruthy() if (!box) return await page.mouse.move(box.x + box.width / 2, box.y + Math.min(120, box.height * 0.25)) @@ -332,7 +365,7 @@ async function revealLongScrollWindow(page: Parameters await page.mouse.wheel(0, -2400) await markTimelineWheelIntent(page, -2400) await settleFrames(page, 2) - await scrollTimelineTo(page, 0) + await setTimelineScrollTopForSetup(page, 0) await settleFrames(page, 2) try { await expect @@ -869,15 +902,22 @@ test.describe("PR0.1 perf probe baseline", () => { await page.goto(sessionPath(project.directory, session.id)) await expect(page.locator(sessionMessageItemSelector).first()).toBeVisible({ timeout: 30_000 }) await expect.poll(async () => page.locator(sessionMessageItemSelector).count()).toBeGreaterThanOrEqual(8) - await resetPerfProbe(page) await hoverTimelineScrollLane(page) await page.mouse.wheel(0, -3600) await settleFrames(page, 2) - await scrollTimelineTo(page, 0) + await setTimelineScrollTopForSetup(page, 0) await settleFrames(page, 2) - await page.mouse.wheel(0, 3600) - await settleFrames(page, 4) - runs.push(await snapshotPerfProbe(page)) + runs.push( + await measurePerfWindow(page, async () => { + const samples = new Set([Math.round((await readTimelineMetrics(page)).scrollTop)]) + for (let index = 0; index < 3; index += 1) { + await page.mouse.wheel(0, 1200) + await settleFrames(page, 2) + samples.add(Math.round((await readTimelineMetrics(page)).scrollTop)) + } + expect(samples.size).toBeGreaterThanOrEqual(2) + }), + ) if (run < 2) await cooldownAfterRun(page) }) } @@ -916,7 +956,7 @@ test.describe("PR0.1 perf probe baseline", () => { await hoverTimelineScrollLane(page) await markTimelineWheelIntent(page, -2400) - await scrollTimelineTo(page, 0) + await setTimelineScrollTopForSetup(page, 0) await settleFrames(page, 4) const atTop = await readTimelineMetrics(page) expect(atTop.maxScrollTop).toBeGreaterThan(4_000) @@ -993,15 +1033,22 @@ test.describe("PR0.1 perf probe baseline", () => { await revealCachedSessionMessagesThroughDriver(page) await revealCachedSessionMessages(page, TIMELINE_RECOMPUTE_SEED_TURN_COUNT) await expect.poll(async () => page.locator(sessionMessageItemSelector).count()).toBeGreaterThanOrEqual(8) - await resetPerfProbe(page) - await page.locator(scrollViewportSelector).first().hover() - for (let index = 0; index < 4; index += 1) { - await page.mouse.wheel(0, index % 2 === 0 ? 2400 : -2400) - await settleFrames(page, 2) - await scrollTimelineTo(page, index % 2 === 0 ? 0 : 1200) - await settleFrames(page, 2) - } - runs.push(await snapshotPerfProbe(page)) + const before = await readTimelineMetrics(page) + expect(before.maxScrollTop).toBeGreaterThan(1200) + await setTimelineScrollTopForSetup(page, 0) + await settlePerfSetup(page) + runs.push( + await measurePerfWindow(page, async () => { + await hoverTimelineScrollLane(page) + const samples = new Set([Math.round((await readTimelineMetrics(page)).scrollTop)]) + for (let index = 0; index < 4; index += 1) { + await page.mouse.wheel(0, -2400) + await settleFrames(page, 2) + samples.add(Math.round((await readTimelineMetrics(page)).scrollTop)) + } + expect(samples.size).toBeGreaterThanOrEqual(2) + }), + ) if (run < 2) await cooldownAfterRun(page) }) } From 75803f1bfe89db74eb0cf54afe5e63f16212cc62 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Sat, 23 May 2026 15:01:46 +0800 Subject: [PATCH 2/2] test: stabilize perf CLS measured window guard --- .../app/e2e/perf/perf-probe-window.unit.ts | 75 ---------------- packages/app/e2e/perf/perf-probe.spec.ts | 86 +++++++++++++++---- packages/app/e2e/perf/perf-window.ts | 24 ++++++ packages/app/e2e/perf/perf-window.unit.ts | 35 ++++++++ 4 files changed, 128 insertions(+), 92 deletions(-) delete mode 100644 packages/app/e2e/perf/perf-probe-window.unit.ts create mode 100644 packages/app/e2e/perf/perf-window.ts create mode 100644 packages/app/e2e/perf/perf-window.unit.ts diff --git a/packages/app/e2e/perf/perf-probe-window.unit.ts b/packages/app/e2e/perf/perf-probe-window.unit.ts deleted file mode 100644 index de7627e22..000000000 --- a/packages/app/e2e/perf/perf-probe-window.unit.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, test } from "bun:test" -import fs from "node:fs/promises" - -const specPath = new URL("./perf-probe.spec.ts", import.meta.url) - -async function readPerfProbeSpec() { - return fs.readFile(specPath, "utf8") -} - -function extractMeasuredWindows(source: string) { - const resetPattern = "await resetPerfProbe(page)" - const snapshotPattern = "snapshotPerfProbe(page)" - const windows: string[] = [] - let cursor = 0 - - while (true) { - const resetIndex = source.indexOf(resetPattern, cursor) - if (resetIndex === -1) break - const snapshotIndex = source.indexOf(snapshotPattern, resetIndex) - if (snapshotIndex === -1) break - windows.push(source.slice(resetIndex, snapshotIndex + snapshotPattern.length)) - cursor = snapshotIndex + snapshotPattern.length - } - - return windows -} - -function extractFunction(source: string, name: string) { - const signatureIndex = source.indexOf(`function ${name}`) - expect(signatureIndex).toBeGreaterThanOrEqual(0) - - const bodyStart = source.indexOf("{", signatureIndex) - expect(bodyStart).toBeGreaterThanOrEqual(0) - - let depth = 0 - for (let index = bodyStart; index < source.length; index += 1) { - const char = source[index] - if (char === "{") depth += 1 - if (char === "}") { - depth -= 1 - if (depth === 0) return source.slice(signatureIndex, index + 1) - } - } - - throw new Error(`Could not parse function ${name}`) -} - -describe("perf probe measured windows", () => { - test("keep setup-only timeline scroll jumps outside measured windows", async () => { - const source = await readPerfProbeSpec() - const violations = extractMeasuredWindows(source).filter((window) => - window.includes("setTimelineScrollTopForSetup(page"), - ) - - expect(violations).toEqual([]) - }) - - test("reject setup-only timeline scroll jumps while a measured window is active", async () => { - const source = await readPerfProbeSpec() - const measurePerfWindow = extractFunction(source, "measurePerfWindow") - const setupScroll = extractFunction(source, "setTimelineScrollTopForSetup") - - expect(measurePerfWindow).toContain("measuredPerfWindowDepth += 1") - expect(measurePerfWindow).toContain("return await snapshotPerfProbe(page)") - expect(measurePerfWindow).toContain("measuredPerfWindowDepth -= 1") - expect(setupScroll).toContain("if (measuredPerfWindowDepth > 0)") - }) - - test("name direct timeline scroll helper as setup-only", async () => { - const source = await readPerfProbeSpec() - - expect(source).not.toContain("function scrollTimelineTo") - expect(source).toContain("function setTimelineScrollTopForSetup") - }) -}) diff --git a/packages/app/e2e/perf/perf-probe.spec.ts b/packages/app/e2e/perf/perf-probe.spec.ts index b7bd62c1c..4301706ea 100644 --- a/packages/app/e2e/perf/perf-probe.spec.ts +++ b/packages/app/e2e/perf/perf-probe.spec.ts @@ -16,6 +16,7 @@ import { composerEvent, type ComposerDriverState, type ComposerWindow } from ".. import { installPerfProbe, resetPerfProbe, snapshotPerfProbe, summarizeScenarioRuns } from "./probe" import { applyPerfProfile, readPerfProfile, shouldRunScenario, type PerfScenarioName } from "./profiles" import { readTimelineDomBudget, shouldAssertTimelineVirtualization } from "./timeline-dom-budget" +import { createPerfWindowGuard } from "./perf-window" import { TIMELINE_RECOMPUTE_SEED_TURN_COUNT, buildHeterogeneousScrollSeedText, @@ -27,6 +28,7 @@ const outputPath = process.env.PAWWORK_PERF_OUTPUT ?? path.join(process.cwd(), "e2e", "perf-results", "pr0.1-baseline.json") const perfBranch = process.env.PAWWORK_PERF_BRANCH ?? "dev" const PERF_PROFILE = readPerfProfile() +const perfWindowGuard = createPerfWindowGuard() const longMarkdown = [ "# Baseline stream", @@ -66,7 +68,6 @@ const longScrollMinimumMovingSamples = 16 const longScrollMinimumDistinctScrollTops = 12 const scenarioResults: ReturnType[] = [] -let measuredPerfWindowDepth = 0 type PerfSdk = ReturnType type PerfProject = { @@ -86,6 +87,10 @@ type TimelineMetrics = { maxScrollTop: number } +type TimelineSetupMetrics = TimelineMetrics & { + layoutTransactionActive: boolean +} + type WheelRouteResult = { events: number movingSamples: number @@ -152,19 +157,61 @@ async function measurePerfWindow( action: () => Promise, ) { await settleFrames(page, 2) - await resetPerfProbe(page) - measuredPerfWindowDepth += 1 - try { - await action() - return await snapshotPerfProbe(page) - } finally { - measuredPerfWindowDepth -= 1 - } + return perfWindowGuard.measure({ + reset: () => resetPerfProbe(page), + action, + snapshot: () => snapshotPerfProbe(page), + }) +} + +async function readTimelineSetupMetrics(page: Parameters[0]) { + const metrics = await page.evaluate( + ({ scrollViewportSelector, turnListSelector }) => { + const list = document.querySelector(turnListSelector) + const viewport = list?.closest(scrollViewportSelector) + if (!(viewport instanceof HTMLElement)) return undefined + const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight) + return { + scrollTop: viewport.scrollTop, + scrollHeight: viewport.scrollHeight, + clientHeight: viewport.clientHeight, + maxScrollTop, + layoutTransactionActive: list.getAttribute("data-layout-transaction-active") === "true", + } + }, + { scrollViewportSelector, turnListSelector: sessionTurnListSelector }, + ) + expect(metrics).toBeTruthy() + return metrics as TimelineSetupMetrics +} + +function sameTimelineSetupMetrics(a: TimelineSetupMetrics, b: TimelineSetupMetrics) { + return ( + Math.round(a.scrollTop) === Math.round(b.scrollTop) && + a.scrollHeight === b.scrollHeight && + a.clientHeight === b.clientHeight && + a.layoutTransactionActive === b.layoutTransactionActive + ) } async function settlePerfSetup(page: Parameters[0]) { - await settleFrames(page, 12) - await page.waitForTimeout(250) + await expect + .poll( + async () => { + const samples: TimelineSetupMetrics[] = [] + for (let index = 0; index < 3; index += 1) { + await settleFrames(page, 1) + samples.push(await readTimelineSetupMetrics(page)) + } + const first = samples[0] + const last = samples[samples.length - 1] + if (!first || !last) return false + const stable = samples.every((sample) => sameTimelineSetupMetrics(first, sample)) + return stable && !last.layoutTransactionActive + }, + { timeout: 5_000 }, + ) + .toBe(true) } async function ensureTerminalClosed(page: Parameters[0]) { @@ -274,9 +321,7 @@ async function readTimelineDriverEvent() { } async function setTimelineScrollTopForSetup(page: Parameters[0], top: number) { - if (measuredPerfWindowDepth > 0) { - throw new Error("setTimelineScrollTopForSetup must run outside perf measured windows") - } + perfWindowGuard.assertSetupAllowed("setTimelineScrollTopForSetup") const found = await page.evaluate( ({ top, scrollViewportSelector, turnListSelector }) => { const list = document.querySelector(turnListSelector) @@ -1035,18 +1080,25 @@ test.describe("PR0.1 perf probe baseline", () => { await expect.poll(async () => page.locator(sessionMessageItemSelector).count()).toBeGreaterThanOrEqual(8) const before = await readTimelineMetrics(page) expect(before.maxScrollTop).toBeGreaterThan(1200) - await setTimelineScrollTopForSetup(page, 0) + const setupScrollTop = Math.min(1200, before.maxScrollTop) + await setTimelineScrollTopForSetup(page, setupScrollTop) await settlePerfSetup(page) runs.push( await measurePerfWindow(page, async () => { await hoverTimelineScrollLane(page) - const samples = new Set([Math.round((await readTimelineMetrics(page)).scrollTop)]) + const start = await readTimelineMetrics(page) + expect(start.scrollTop).toBeGreaterThan(1) + const samples = new Set([Math.round(start.scrollTop)]) + let minScrollTop = start.scrollTop for (let index = 0; index < 4; index += 1) { await page.mouse.wheel(0, -2400) await settleFrames(page, 2) - samples.add(Math.round((await readTimelineMetrics(page)).scrollTop)) + const next = await readTimelineMetrics(page) + minScrollTop = Math.min(minScrollTop, next.scrollTop) + samples.add(Math.round(next.scrollTop)) } expect(samples.size).toBeGreaterThanOrEqual(2) + expect(minScrollTop).toBeLessThan(start.scrollTop - 1) }), ) if (run < 2) await cooldownAfterRun(page) diff --git a/packages/app/e2e/perf/perf-window.ts b/packages/app/e2e/perf/perf-window.ts new file mode 100644 index 000000000..9d4d71540 --- /dev/null +++ b/packages/app/e2e/perf/perf-window.ts @@ -0,0 +1,24 @@ +export function createPerfWindowGuard() { + let measuredWindowDepth = 0 + + return { + assertSetupAllowed(name: string) { + if (measuredWindowDepth > 0) throw new Error(`${name} must run outside perf measured windows`) + }, + + async measure(input: { + reset: () => Promise + action: () => Promise + snapshot: () => Promise + }) { + await input.reset() + measuredWindowDepth += 1 + try { + await input.action() + return await input.snapshot() + } finally { + measuredWindowDepth -= 1 + } + }, + } +} diff --git a/packages/app/e2e/perf/perf-window.unit.ts b/packages/app/e2e/perf/perf-window.unit.ts new file mode 100644 index 000000000..7b52a2f9c --- /dev/null +++ b/packages/app/e2e/perf/perf-window.unit.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { createPerfWindowGuard } from "./perf-window" + +describe("perf window guard", () => { + test("rejects setup-only work inside active measured windows", async () => { + const guard = createPerfWindowGuard() + + expect(() => guard.assertSetupAllowed("setup")).not.toThrow() + await expect( + guard.measure({ + reset: async () => {}, + action: async () => { + expect(() => guard.assertSetupAllowed("setup")).toThrow("setup must run outside perf measured windows") + }, + snapshot: async () => "ok", + }), + ).resolves.toBe("ok") + expect(() => guard.assertSetupAllowed("setup")).not.toThrow() + }) + + test("clears active measured window after action failures", async () => { + const guard = createPerfWindowGuard() + + await expect( + guard.measure({ + reset: async () => {}, + action: async () => { + throw new Error("action failed") + }, + snapshot: async () => "unreachable", + }), + ).rejects.toThrow("action failed") + expect(() => guard.assertSetupAllowed("setup")).not.toThrow() + }) +})