From 4e5df802c72df23470cd2fb80cc9cb1a82424a9f Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:30:09 +1000 Subject: [PATCH] test(app): bench dragging the review's edge --- packages/app/perf/fixtures/drag.ts | 105 ++++++++++++++++++++++++++++ packages/app/perf/timeline.bench.ts | 45 ++++++++++-- 2 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 packages/app/perf/fixtures/drag.ts diff --git a/packages/app/perf/fixtures/drag.ts b/packages/app/perf/fixtures/drag.ts new file mode 100644 index 00000000000..47837f2bb09 --- /dev/null +++ b/packages/app/perf/fixtures/drag.ts @@ -0,0 +1,105 @@ +import type { Page } from "@playwright/test" + +/** A divider to drag: its centre, how far it can travel each way (px), and the display's frame period (ms). */ +export type Divider = { x: number; y: number; left: number; right: number; period: number } + +type Leg = { seconds: number; at: (progress: number) => number } + +const ease = (from: number, to: number, seconds: number): Leg => ({ + seconds, + at: (progress) => from + ((to - from) * (1 - Math.cos(Math.PI * progress))) / 2, +}) + +// Whole cycles, so a jiggle ends where it began. +const jiggle = (around: number, amplitude: number, cycles: number, seconds: number): Leg => ({ + seconds, + at: (progress) => around + amplitude * Math.sin(2 * Math.PI * cycles * progress), +}) + +/** + * How a hand drags a divider, as offsets from where the drag starts against time, scaled by the room each way: a slow + * stretch one way, a jiggle there, a fast throw the other way, a slow pull back, a small jiggle and home. 4.95 s. + */ +function legs(divider: Divider) { + const l = -divider.left + const r = divider.right + + return [ + ease(0, 0.8 * l, 1.2), + jiggle(0.8 * l, 0.15 * divider.left, 3, 0.75), + ease(0.8 * l, r, 0.6), + ease(r, l, 1.5), + ease(l, 0.4 * l, 0.4), + jiggle(0.4 * l, 0.1 * divider.left, 3, 0.5), + ease(0.4 * l, 0, 0.5), + ] +} + +/** + * Presses the divider, moves the pointer once per display frame along the drag above without letting go, and releases + * it where it started. strobe's driver has no pointer drag (`page.move` does not hold a button, and its `until: + * "frame"` waits two animation frames and a round trip per move), so Playwright's mouse drives the drag, from the + * runner, paced by the display's frame period on the wall clock. A frame the page misses is skipped, as a hand keeps + * moving while the screen lags. + */ +export async function dragDivider(mouse: Page["mouse"], divider: Divider) { + const path = legs(divider) + const total = path.reduce((sum, leg) => sum + leg.seconds, 0) + + const offset = (seconds: number) => { + let left = seconds + + for (const leg of path) { + if (left <= leg.seconds) return leg.at(left / leg.seconds) + left -= leg.seconds + } + + return 0 + } + + await mouse.move(divider.x, divider.y) + await mouse.down() + const began = performance.now() + + for (let frame = 1; ; frame++) { + const wait = began + frame * divider.period - performance.now() + + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)) + frame = Math.max(frame, Math.floor((performance.now() - began) / divider.period)) + const seconds = (performance.now() - began) / 1000 + + if (seconds >= total) break + await mouse.move(divider.x + offset(seconds), divider.y) + } + + await mouse.move(divider.x, divider.y) + await mouse.up() +} + +/** + * Reads, in the page, where the divider `selector` sits, the room its panel `panel` has to shrink to `min` and grow to + * the row `row` less `rest` (px), and the display's frame period over 30 frames. Only in setup: it reads layout. + */ +export function measureDivider(input: { selector: string; panel: string; row: string; min: number; rest: number }) { + return new Promise((resolve) => { + const box = document.querySelector(input.selector)!.getBoundingClientRect() + const width = document.querySelector(input.panel)!.getBoundingClientRect().width + const row = document.querySelector(input.row)!.getBoundingClientRect().width + const times: number[] = [] + + const tick = (time: number) => { + times.push(time) + + if (times.length < 31) return requestAnimationFrame(tick) + resolve({ + x: box.left + box.width / 2, + y: box.top + box.height / 2, + left: Math.max(0, Math.min(360, width - input.min - 10)), + right: Math.max(0, Math.min(160, row - input.rest - width - 10)), + period: (times[30]! - times[0]!) / 30, + }) + } + + requestAnimationFrame(tick) + }) +} diff --git a/packages/app/perf/timeline.bench.ts b/packages/app/perf/timeline.bench.ts index 7c3cacdf073..bf08ebab490 100644 --- a/packages/app/perf/timeline.bench.ts +++ b/packages/app/perf/timeline.bench.ts @@ -1,7 +1,9 @@ // One long session (120 turns of markdown, code fences, tool calls and diffs) at normal speed. import { bench, web } from "@hona/strobe" -import { editTools } from "./fixtures/diffs" +import { editTools, reviewDiffs } from "./fixtures/diffs" +import { dragDivider, measureDivider, type Divider } from "./fixtures/drag" import { cached } from "./fixtures/mock" +import { openReview } from "./fixtures/review" import { composer, mockTimelineSession, oldestPrompt, timeline } from "./fixtures/timeline" const find = '[data-component="timeline-search-bar"] input' @@ -13,25 +15,47 @@ const edit = '[data-scope="apply-patch"] [data-slot="accordion-trigger"]' const diff = '[data-component="apply-patch-file-diff"] diffs-container >>> [data-line]' +const drag = "drag the review's edge" + +// The divider between the session and the side panel, read once the review is open. The session panel keeps at least +// 400px, the side panel 320px and the 8px gap. +let divider: Divider | undefined + export default bench({ targets: { web: web({ viewport: { width: 1366, height: 768 }, - prepare: async ({ case: name, playwright, mock, url }) => - mockTimelineSession(cached(playwright.page, mock), { + prepare: async ({ case: name, playwright, mock, url }) => ({ + ...(await mockTimelineSession(cached(playwright.page, mock), { server: new URL(url).origin, // The edits alone, so the case measures the diff, not the history around it. historyTurns: name === "expand an edit" ? 0 : 120, collapsedEdits: name === "expand an edit", tools: name === "expand an edit" ? await editTools() : undefined, - }), + vcsDiff: name === drag ? reviewDiffs() : undefined, + })), + mouse: playwright.page.mouse, + }), url: ({ prepared }) => prepared.path, }), }, - async setup({ page }) { + async setup({ case: name, page }) { await page.waitFor(timeline, { timeout: 60_000 }) await page.waitFor(composer) + + if (name === drag) { + await openReview(page) + await page.idle() + divider = await page.eval(measureDivider, { + selector: '[data-slot="session-chat-panel"] > div > [data-component="resize-handle"]', + panel: '[data-slot="session-chat-panel"]', + row: "[data-pane-row]", + min: 400, + rest: 328, + }) + } + await page.idle() }, @@ -85,5 +109,16 @@ export default bench({ await page.idle() }, }, + + // The divider beside the review (72 changed files) held for five seconds and dragged as a hand does, a pointer + // move every display frame: a slow stretch toward the session, jiggles, a fast throw toward the review and a slow + // pull back (fixtures/drag.ts). The timeline narrows and widens on every frame, so the rows in view rewrap; each + // frame should keep up with the display. + [drag]: { + primary: "frames.dropped.pct", + metrics: ["main.work", "main.layout", "main.style", "main.script", "frames.interval.p95"], + run: ({ prepared }) => dragDivider(prepared.mouse, divider!), + reset: ({ page }) => page.idle(), + }, }, })