From f4bedea048641318293a6d9e0c61609af07ba250 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 14 Oct 2026 00:10:26 +0000 Subject: [PATCH 1/6] perf(studio): settle the edit accuracy bench on frames and network, not a fixed window settled() waited for the box and the preview frames to hold still for 500 ms, 7 to 15 times a case. It now resolves once they read the same on 3 animation frames in a row while the Studio page has no request in flight (the event stream and server-rendered timeline thumbnails excepted) and the server has announced every project file written since its last file-change event, so a save, a preview reload or a reload the event starts still holds it open. EDIT_ACCURACY_STILL_MS restores the fixed window with no network signal. Also polls studio_seek every 50 ms instead of 250 ms while Studio opens. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/studio/AGENTS.md | 5 ++ .../studio/tests/e2e/edit-accuracy/case.mjs | 83 +++++++++++++++++-- .../studio/tests/e2e/edit-accuracy/report.mjs | 2 +- .../tests/e2e/edit-accuracy/settle.test.mjs | 58 ++++++++++++- 4 files changed, 138 insertions(+), 10 deletions(-) diff --git a/packages/studio/AGENTS.md b/packages/studio/AGENTS.md index 325b5c9aef1..fe36d0e4423 100644 --- a/packages/studio/AGENTS.md +++ b/packages/studio/AGENTS.md @@ -134,6 +134,11 @@ bun run --cwd packages/studio test:edit-accuracy -- --grid pr --filter '^resize- - Each run writes its results, a table and a candidate baseline to `tests/e2e/evidence/edit-accuracy//` (git-ignored; `--out` moves it); a failing case also gets its screens and saved files under `cases//`. +- A measurement waits until the box and the preview frames read the same on 3 + animation frames in a row, with no Studio request in flight and every file + write announced by the server. `EDIT_ACCURACY_STILL_MS=500` swaps that for + the older fixed window of 500 ms with no network signal, if the faster check + ever proves flaky on CI. - CI runs the full grid in shards and `ratchet.mjs` gates the result against the base branch's `baseline.json`; its header states the rules. When cases newly pass, commit the `baseline.json` from the `edit-accuracy-gate` artifact. diff --git a/packages/studio/tests/e2e/edit-accuracy/case.mjs b/packages/studio/tests/e2e/edit-accuracy/case.mjs index e2363ded2f9..091661ab5ba 100644 --- a/packages/studio/tests/e2e/edit-accuracy/case.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/case.mjs @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { classifyTweenPropertyGroup } from "../../../../parsers/src/gsapConstants.ts"; import { parseGsapScript } from "../../../../parsers/src/gsapParser.ts"; -import { readFileSync } from "node:fs"; +import { readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { COMPOSITION, FIXTURE_CDN, PLAYHEAD, localAsset } from "./grid.mjs"; import { @@ -231,8 +231,58 @@ export async function measure(ctx) { return { map, quad, size, visible: visibleQuad(quad, size, parseInset(box.clip)) }; } -// 500 ms, from 1000: the window restarts on any change, so a shorter one still waits out a slow save or swap. -const STILL_MS = Number(process.env.EDIT_ACCURACY_STILL_MS ?? 500); +// Still means the same reads on STILL_FRAMES animation frames in a row with the page's network quiet. +// EDIT_ACCURACY_STILL_MS restores the old check: a fixed window, in ms, of unchanged reads with no network signal. +const STILL_MS = process.env.EDIT_ACCURACY_STILL_MS + ? Number(process.env.EDIT_ACCURACY_STILL_MS) + : null; +export const STILL_FRAMES = 3; + +// The server's event stream stays open for the page's life; timeline thumbnails are rendered by the server +// for the timeline strip, can take seconds, and never change the preview. +const backgroundRequest = (request) => + request.resourceType() === "eventsource" || + /\/api\/projects\/[^/]+\/thumbnail\//.test(request.url()); + +/** + * The Studio page's network, for the stillness check: requests in flight, a counter bumped by any request or + * server event, and when the server last announced each project file. Studio's save requests, its reads of + * the files and the preview's loads all show here; a server `file-change` event can start another reload. + */ +async function watchNetwork(page) { + const net = { inflight: new Set(), activity: 0, announced: new Map(), since: Date.now() }; + const done = (request) => { + net.inflight.delete(request); + net.activity++; + }; + page.on("request", (request) => { + if (!backgroundRequest(request)) net.inflight.add(request); + net.activity++; + }); + page.on("requestfinished", done); + page.on("requestfailed", done); + const cdp = await page.createCDPSession(); + cdp.on("Network.eventSourceMessageReceived", ({ eventName, data }) => { + net.activity++; + if (eventName === "file-change") net.announced.set(JSON.parse(data).path, Date.now()); + }); + await cdp.send("Network.enable"); + return net; +} + +/** A file written after the server last announced it: its `file-change` event, and what Studio does on it, is still to come. */ +const unannounced = ({ dir, files, net }) => + files.some( + (f) => statSync(join(dir, f)).mtimeMs > Math.max(net.since, net.announced.get(f) ?? 0), + ); + +/** Whether the page is waiting on the network; a request whose frame is gone never finishes. */ +// fallow-ignore-next-line complexity +export function networkBusy(ctx) { + for (const request of ctx.net.inflight) + if (request.frame()?.detached !== false) ctx.net.inflight.delete(request); + return { busy: ctx.net.inflight.size > 0 || unannounced(ctx), activity: ctx.net.activity }; +} // Studio reloads an edited preview in a shadow iframe (`_t` in its URL) and swaps it in when painted. const previewFrames = (page) => @@ -255,31 +305,47 @@ async function hiddenTarget(page, selector = "#target") { // Keyframed cases only: waiting out the swap lands a later undo in the preview's burst of requests. export const swapPending = (ctx) => Boolean(ctx.keys) && hiddenTarget(ctx.page, ctx.selector); -/** Restart the stillness window: a pending swap, a changed set of preview frames, or the box moved. */ +// fallow-ignore-next-line complexity +const netMoved = (start, now) => + Boolean(now.net?.busy) || now.net?.activity !== start.net?.activity; + +/** Restart the stillness window: a pending swap, a changed set of preview frames, the box moved, or network. */ +// fallow-ignore-next-line complexity export const unsettledBy = (start, now) => start.pending || now.pending || + netMoved(start, now) || now.frames !== start.frames || quadDistance(now.m.visible, start.m.visible) >= 0.01; -/** Measures once the shown preview and the box have held still for STILL_MS; Studio updates both after a save. */ +/** Whether a window that began at `start`, with `still` unchanged reads since, has held long enough. */ +// fallow-ignore-next-line complexity +export const heldStill = (start, still, since, stillMs = STILL_MS) => + !start.pending && + !start.net?.busy && + (stillMs === null ? still >= STILL_FRAMES : Date.now() - since >= stillMs); + +/** Measures once the shown preview and the box have held still; Studio updates both after a save. */ // fallow-ignore-next-line complexity export async function settled(ctx, timeout = 15_000) { const deadline = Date.now() + timeout; + const watchNet = STILL_MS === null && ctx.net; const read = async () => ({ m: await measure(ctx), frames: previewFrames(ctx.page), pending: await swapPending(ctx), + ...(watchNet && { net: networkBusy(ctx) }), }); let start = await read(); let now = start; // Compared with the window's first read, so a drift too slow to show read to read still restarts it. - for (let since = Date.now(); start.pending || Date.now() - since < STILL_MS; ) { + for (let [since, still] = [Date.now(), 0]; !heldStill(start, still, since); ) { // A preview that never holds still is a Studio defect: the metrics it feeds fail, the rest still count. if (Date.now() > deadline) return { ...now.m, unsettled: true }; await nextFrame(ctx.page); now = await read(); - if (unsettledBy(start, now)) [start, since] = [now, Date.now()]; + if (unsettledBy(start, now)) [start, since, still] = [now, Date.now(), 0]; + else still++; } return now.m; } @@ -290,7 +356,7 @@ export async function openStudio(ctx) { ctx.handles = null; await ctx.page.waitForFunction(() => window.__editBench?.has("studio_seek"), { timeout: 90_000 }); let seek = null; - for (const deadline = Date.now() + 30_000; Date.now() < deadline; await sleep(250)) { + for (const deadline = Date.now() + 30_000; Date.now() < deadline; await sleep(50)) { seek = await ctx.page .evaluate((time) => window.__editBench.call("studio_seek", { time }), ctx.playhead) .catch(String); @@ -876,6 +942,7 @@ export async function inStudio({ browser, spec, dir, files, url, evidence }, dri handles: null, playhead: spec.playhead ?? PLAYHEAD, keys: spec.keys, + net: await watchNetwork(page), }; const consoleErrors = []; page.on("pageerror", (e) => consoleErrors.push(e.message)); diff --git a/packages/studio/tests/e2e/edit-accuracy/report.mjs b/packages/studio/tests/e2e/edit-accuracy/report.mjs index 5f8a3018bf9..ffb570756bb 100644 --- a/packages/studio/tests/e2e/edit-accuracy/report.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/report.mjs @@ -174,7 +174,7 @@ function table(summary, meta, results) { `Pass: tracking, press jump, teleport, drop, reload and render ≤ ${LIMIT_PX} px; undo and redo byte-identical with the box ≤ ${LIMIT_PX} px; no more frames over ${DROPPED_FRAME_MS} ms than the blank-page control, and no frame over ${WORK_MS} ms of main-thread work.`, "", `Undo or redo left different bytes in ${summary.bytesDiffer.undo} undo and ${summary.bytesDiffer.redo} redo cases.`, - `The preview never held still for 1 s within 15 s in ${summary.unsettled} cases; the metrics that snapshot feeds fail.`, + `The preview never held still within 15 s in ${summary.unsettled} cases; the metrics that snapshot feeds fail.`, `An undo or redo write never landed within 60 s in ${summary.undoTimeouts} cases; undo fails there.`, `An undo or redo write landed later than ${UNDO_WRITE_MAX_MS} ms after its key in ${summary.undoSlow} cases; undo fails there.`, `The producer failed to render ${summary.renderErrors} cases; render fails there.`, diff --git a/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs b/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs index 82f051e29b7..6c96c7f4542 100644 --- a/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs @@ -1,5 +1,8 @@ +import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { swapPending, unsettledBy } from "./case.mjs"; +import { STILL_FRAMES, heldStill, networkBusy, swapPending, unsettledBy } from "./case.mjs"; import { heldSize } from "./sequences.mjs"; describe("settling on the shown preview", () => { @@ -47,6 +50,59 @@ describe("settling on the shown preview", () => { }); }); +describe("settling on the page's network", () => { + const read = (busy, activity) => ({ + pending: false, + frames: "a", + m: { visible: [[0, 0]] }, + net: { busy, activity }, + }); + + it("restarts on a request in flight or any network activity since the window began", () => { + expect(unsettledBy(read(false, 3), read(false, 3))).toBe(false); + expect(unsettledBy(read(false, 3), read(true, 3))).toBe(true); + expect(unsettledBy(read(false, 3), read(false, 4))).toBe(true); + }); + + it("holds after STILL_FRAMES quiet reads, or after the fixed window when one is set", () => { + const since = Date.now(); + expect(heldStill(read(false, 0), STILL_FRAMES - 1, since, null)).toBe(false); + expect(heldStill(read(false, 0), STILL_FRAMES, since, null)).toBe(true); + expect(heldStill(read(true, 0), STILL_FRAMES, since, null)).toBe(false); + expect(heldStill({ ...read(false, 0), pending: true }, STILL_FRAMES, since, null)).toBe(false); + expect(heldStill(read(false, 0), 100, since, 500)).toBe(false); + expect(heldStill(read(false, 0), 0, since - 500, 500)).toBe(true); + }); + + it("waits for requests in live frames and for the server to announce each written file", () => { + const dir = mkdtempSync(join(tmpdir(), "edit-bench-net-")); + try { + writeFileSync(join(dir, "index.html"), ""); + const since = Date.now() / 1000; + utimesSync(join(dir, "index.html"), since - 10, since - 10); + const request = (detached) => ({ frame: () => (detached === null ? null : { detached }) }); + const live = request(false); + const net = { + inflight: new Set([live, request(true), request(null)]), + activity: 7, + announced: new Map(), + since: since * 1000, + }; + const ctx = { dir, files: ["index.html"], net }; + expect(networkBusy(ctx)).toEqual({ busy: true, activity: 7 }); + expect([...net.inflight]).toEqual([live]); + net.inflight.clear(); + expect(networkBusy(ctx).busy).toBe(false); + utimesSync(join(dir, "index.html"), since + 5, since + 5); + expect(networkBusy(ctx).busy).toBe(true); + net.announced.set("index.html", since * 1000 + 6000); + expect(networkBusy(ctx).busy).toBe(false); + } finally { + rmSync(dir, { recursive: true }); + } + }); +}); + describe("the size auto-record keys hold", () => { const keys = new Map([ [3, { width: 440, height: 294 }], From 72819c12afe9d25b39d4de5e3481d4efc5e331ef Mon Sep 17 00:00:00 2001 From: James Date: Wed, 14 Oct 2026 00:10:43 +0000 Subject: [PATCH 2/6] perf(studio): keep one producer Chrome open across edit accuracy renders Every render launched and closed its own producer Chrome. The run now holds a lease on the engine's pooled browser (a capture session with the renders' launch settings and its page closed), so each render opens a page in that browser instead. Each render still gets its own page and file server, and clears the browser cache first. A held browser that failed to launch or whose Chrome died is dropped and held again, never reused. Studio's server stays per case: it serves one project folder, stamps it on start and keeps that folder's undo history open, so switching folders under it would leak history between cases. Its start is polled every 50 ms instead of 200 ms. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/studio/AGENTS.md | 3 + .../studio/tests/e2e/edit-accuracy/case.mjs | 2 +- .../studio/tests/e2e/edit-accuracy/render.mjs | 89 +++++++++++++++---- .../tests/e2e/edit-accuracy/render.test.mjs | 73 +++++++++++++-- .../studio/tests/e2e/edit-accuracy/run.mjs | 8 +- 5 files changed, 152 insertions(+), 23 deletions(-) diff --git a/packages/studio/AGENTS.md b/packages/studio/AGENTS.md index fe36d0e4423..baa1c245c0a 100644 --- a/packages/studio/AGENTS.md +++ b/packages/studio/AGENTS.md @@ -139,6 +139,9 @@ bun run --cwd packages/studio test:edit-accuracy -- --grid pr --filter '^resize- write announced by the server. `EDIT_ACCURACY_STILL_MS=500` swaps that for the older fixed window of 500 ms with no network signal, if the faster check ever proves flaky on CI. +- The run keeps one producer Chrome open for every render; each render still + gets its own page and file server. Studio's server is started per case, + because it serves one project folder and keeps that folder's undo history. - CI runs the full grid in shards and `ratchet.mjs` gates the result against the base branch's `baseline.json`; its header states the rules. When cases newly pass, commit the `baseline.json` from the `edit-accuracy-gate` artifact. diff --git a/packages/studio/tests/e2e/edit-accuracy/case.mjs b/packages/studio/tests/e2e/edit-accuracy/case.mjs index 091661ab5ba..b2638817ef8 100644 --- a/packages/studio/tests/e2e/edit-accuracy/case.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/case.mjs @@ -85,7 +85,7 @@ export async function startServer(cli, dir, port, log, home) { child.once("exit", () => liveServers.delete(child)); child.stdout.on("data", (d) => log.push(String(d))); child.stderr.on("data", (d) => log.push(String(d))); - for (const deadline = Date.now() + 60_000; Date.now() < deadline; await sleep(200)) { + for (const deadline = Date.now() + 60_000; Date.now() < deadline; await sleep(50)) { if (child.exitCode !== null) throw new Error(`studio exited ${child.exitCode}: ${log.join("").slice(-500)}`); const announced = Number(announcedPort(log)); diff --git a/packages/studio/tests/e2e/edit-accuracy/render.mjs b/packages/studio/tests/e2e/edit-accuracy/render.mjs index edc6c4e1b9e..ffda8d13d32 100644 --- a/packages/studio/tests/e2e/edit-accuracy/render.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/render.mjs @@ -79,6 +79,73 @@ function markNoTimeline() { }).observe(document, { childList: true, subtree: true }); } +const CAPTURE = { + width: COMPOSITION.width, + height: COMPOSITION.height, + fps: FPS, + format: "jpeg", + quality: 100, +}; +// A composition with no GSAP never registers a timeline; the default 45 s wait for one changes no pixel. +// Static-frame dedup opens a second page that would fetch GSAP from the CDN; one frame needs none. +const CAPTURE_CONFIG = { playerReadyTimeout: 10_000, staticFrameDedup: false }; + +/** A capture session with the renders' launch configuration and its page closed: only its browser lease is left. */ +async function openHold(framesDir) { + // requiresWebGpu as the renders find it from the fixtures, so the launch configuration matches theirs. + const session = await createCaptureSession( + "about:blank", + framesDir, + { ...CAPTURE, requiresWebGpu: false }, + null, + CAPTURE_CONFIG, + ); + await session.page.close(); + // closeCaptureSession then only releases the browser lease. + session.pageReleased = true; + return session; +} + +let hold = null; + +/** + * Keeps the producer's Chrome open between renders. The engine pools a browser per launch configuration and + * closes it when its last session closes; this session holds a lease with the same configuration and no page, + * so each render opens a page in that browser instead of launching one. Call the result to let it close. + */ +export async function holdProducerBrowser(framesDir) { + const current = { framesDir, session: openHold(framesDir) }; + hold = current; + // A hold that fails here is taken again by the next render. + await current.session.catch(() => undefined); + return async () => { + if (hold === current) hold = null; + const session = await current.session.catch(() => null); + if (session) await closeCaptureSession(session); + }; +} + +/** The held browser; a hold that failed or whose Chrome died is dropped and taken again, never reused. */ +// fallow-ignore-next-line complexity +export async function heldBrowser() { + if (!hold) return null; + const taken = hold.session; + const session = await taken.catch(() => null); + if (session?.browser.connected) return session.browser; + if (hold.session === taken) { + hold.session = openHold(hold.framesDir); + if (session) void closeCaptureSession(session).catch(() => undefined); + } + return (await hold.session.catch(() => null))?.browser ?? null; +} + +let warnedNotHeld = false; +function warnUnlessHeld(held, browser) { + if (!held || browser === held || warnedNotHeld) return; + warnedNotHeld = true; + console.warn("edit bench: the render did not reuse the held producer browser"); +} + /** Renders `dir` at `time` and measures the target's box in the frame. */ export async function renderBox(dir, decoder, time = PLAYHEAD) { const framesDir = join(dir, ".bench-frames"); @@ -86,21 +153,13 @@ export async function renderBox(dir, decoder, time = PLAYHEAD) { const server = await createFileServer({ projectDir: dir, port: 0, fps: FPS }); let session; try { - session = await createCaptureSession( - server.url, - framesDir, - { - width: COMPOSITION.width, - height: COMPOSITION.height, - fps: FPS, - format: "jpeg", - quality: 100, - }, - null, - // A composition with no GSAP never registers a timeline; the default 45 s wait for one changes no pixel. - // Static-frame dedup opens a second page that would fetch GSAP from the CDN; one frame needs none. - { playerReadyTimeout: 10_000, staticFrameDedup: false }, - ); + const held = await heldBrowser(); + session = await createCaptureSession(server.url, framesDir, CAPTURE, null, CAPTURE_CONFIG); + warnUnlessHeld(held, session.browser); + // A fresh page in a reused browser: nothing an earlier case's render cached may serve this one. + const cdp = await session.page.createCDPSession(); + await cdp.send("Network.clearBrowserCache"); + await cdp.detach(); // The producer's own browser would fetch GSAP from the CDN; a stalled fetch renders the 0 s frame. await serveFixtureAssetsLocally(session.page); // Without the opt-out, a project with no timeline waits out playerReadyTimeout on every render. diff --git a/packages/studio/tests/e2e/edit-accuracy/render.test.mjs b/packages/studio/tests/e2e/edit-accuracy/render.test.mjs index 3373b8ff511..658118ad1ef 100644 --- a/packages/studio/tests/e2e/edit-accuracy/render.test.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/render.test.mjs @@ -24,18 +24,26 @@ const page = { createCDPSession: async () => ({ on: (event, handler) => (handlers[event] = handler), send: async (method, params) => void sent.push({ method, params }), + detach: async () => undefined, }), evaluate: async () => null, + close: async () => void (page.closed = true), }; +const closed = []; +const producer = { browser: { connected: true }, fail: false }; vi.mock("../../../../producer/src/index.js", () => ({ createFileServer: async () => ({ url: "http://127.0.0.1:1", close() {} }), - createCaptureSession: async (...args) => (sessionArgs.push(args), { page }), + createCaptureSession: async (...args) => { + sessionArgs.push(args); + if (producer.fail) throw new Error("chrome did not launch"); + return { page, browser: producer.browser }; + }, // Navigation: Chrome pauses the fixture's GSAP request here when it is intercepted. initializeSession: async () => handlers["Fetch.requestPaused"]?.({ requestId: "1", request: { url: gsapUrl } }), captureFrameToBuffer: async () => ({ buffer: Buffer.alloc(0) }), - closeCaptureSession: async () => undefined, + closeCaptureSession: async (session) => void closed.push({ ...session }), })); const decoder = { evaluate: async () => ({ left: 0, right: 1, top: 0, bottom: 1, area: 1 }) }; @@ -57,12 +65,16 @@ describe("renderBox", () => { it("serves the fixture's GSAP from the repo and opens no second page that would fetch it", async () => { sent.length = 0; await render(); - expect(sent.map((s) => s.method)).toEqual(["Fetch.enable", "Fetch.fulfillRequest"]); - const { patterns } = sent[0].params; + expect(sent.map((s) => s.method)).toEqual([ + "Network.clearBrowserCache", + "Fetch.enable", + "Fetch.fulfillRequest", + ]); + const { patterns } = sent[1].params; expect(patterns).toEqual([{ urlPattern: patterns[0].urlPattern }]); expect(gsapUrl).toMatch(chromeUrlPattern(patterns[0].urlPattern)); const gsap = readFileSync(require.resolve("gsap/dist/gsap.min.js")).toString("base64"); - expect(sent[1].params.body).toBe(gsap); + expect(sent[2].params.body).toBe(gsap); expect(sessionArgs[0][4]).toMatchObject({ staticFrameDedup: false }); }); @@ -70,4 +82,55 @@ describe("renderBox", () => { expect(await render("
")).toEqual(["markNoTimeline"]); expect(await render("")).toEqual([]); }); + + it("holds the producer browser with the renders' launch settings and no page of its own", async () => { + const { holdProducerBrowser } = await import("./render.mjs"); + const dir = mkdtempSync(join(tmpdir(), "edit-bench-hold-")); + sessionArgs.length = 0; + closed.length = 0; + page.closed = false; + try { + const release = await holdProducerBrowser(dir); + expect(page.closed).toBe(true); + await render(); + const [held, rendered] = sessionArgs; + expect(held[2]).toEqual({ ...rendered[2], requiresWebGpu: false }); + expect(held[4]).toEqual(rendered[4]); + await release(); + expect(closed.at(-1)).toMatchObject({ pageReleased: true }); + } finally { + rmSync(dir, { recursive: true }); + } + }); + + it("drops a held browser that died or never launched and holds a new one", async () => { + const { heldBrowser, holdProducerBrowser } = await import("./render.mjs"); + const dir = mkdtempSync(join(tmpdir(), "edit-bench-hold-")); + const first = { connected: true }; + producer.browser = first; + try { + const release = await holdProducerBrowser(dir); + expect(await heldBrowser()).toBe(first); + // Its Chrome crashed: the dead hold is released and a new one taken. + first.connected = false; + const second = { connected: true }; + producer.browser = second; + closed.length = 0; + expect(await heldBrowser()).toBe(second); + expect(closed).toHaveLength(1); + // A launch that fails is not kept either: the next call tries again. + second.connected = false; + producer.fail = true; + expect(await heldBrowser()).toBeNull(); + producer.fail = false; + const third = { connected: true }; + producer.browser = third; + expect(await heldBrowser()).toBe(third); + await release(); + } finally { + producer.fail = false; + producer.browser = { connected: true }; + rmSync(dir, { recursive: true }); + } + }); }); diff --git a/packages/studio/tests/e2e/edit-accuracy/run.mjs b/packages/studio/tests/e2e/edit-accuracy/run.mjs index c196e224c88..5794cfe53f9 100644 --- a/packages/studio/tests/e2e/edit-accuracy/run.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/run.mjs @@ -15,7 +15,7 @@ import { buildGrid, writeFixture } from "./grid.mjs"; import { killServers, runCase, startServer, stopServer } from "./case.mjs"; import { runSequence } from "./sequences.mjs"; import { METRICS, score, writeReport } from "./report.mjs"; -import { renderBox } from "./render.mjs"; +import { holdProducerBrowser, renderBox } from "./render.mjs"; import { aabb, boxDistance } from "./geometry.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -218,11 +218,15 @@ const browsers = await Promise.all( }), ), ); +// One producer Chrome for the run; each render still gets its own page and file server. +const producerRoot = mkdtempSync(join(tmpdir(), "hf-edit-accuracy-producer-")); +liveRoots.add(producerRoot); +const releaseProducer = await holdProducerBrowser(producerRoot); try { const decoders = await Promise.all(browsers.map((b) => b.newPage())); await runChunks([...cases], browsers, decoders, results); } finally { - await Promise.all(browsers.map((b) => b.close())); + await Promise.all([releaseProducer(), ...browsers.map((b) => b.close())]); } results.sort((a, b) => a.id.localeCompare(b.id)); const meta = { From 67f9a4044776e9fb5a519abbfd6e1e0470a1f3e7 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 14 Oct 2026 04:43:05 +0000 Subject: [PATCH 3/6] test(studio): import render.mjs at the top of the edit accuracy render test Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/studio/tests/e2e/edit-accuracy/render.test.mjs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/studio/tests/e2e/edit-accuracy/render.test.mjs b/packages/studio/tests/e2e/edit-accuracy/render.test.mjs index 658118ad1ef..993b75ca05a 100644 --- a/packages/studio/tests/e2e/edit-accuracy/render.test.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/render.test.mjs @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { FIXTURE_CDN } from "./grid.mjs"; +import { heldBrowser, holdProducerBrowser, renderBox } from "./render.mjs"; const require = createRequire(import.meta.url); const gsapUrl = `${FIXTURE_CDN}npm/gsap@${require("gsap/package.json").version}/dist/gsap.min.js`; @@ -49,7 +50,6 @@ vi.mock("../../../../producer/src/index.js", () => ({ const decoder = { evaluate: async () => ({ left: 0, right: 1, top: 0, bottom: 1, area: 1 }) }; async function render(html) { - const { renderBox } = await import("./render.mjs"); const dir = mkdtempSync(join(tmpdir(), "edit-bench-render-")); if (html) writeFileSync(join(dir, "index.html"), html); onNewDocument.length = 0; @@ -84,7 +84,6 @@ describe("renderBox", () => { }); it("holds the producer browser with the renders' launch settings and no page of its own", async () => { - const { holdProducerBrowser } = await import("./render.mjs"); const dir = mkdtempSync(join(tmpdir(), "edit-bench-hold-")); sessionArgs.length = 0; closed.length = 0; @@ -104,7 +103,6 @@ describe("renderBox", () => { }); it("drops a held browser that died or never launched and holds a new one", async () => { - const { heldBrowser, holdProducerBrowser } = await import("./render.mjs"); const dir = mkdtempSync(join(tmpdir(), "edit-bench-hold-")); const first = { connected: true }; producer.browser = first; From 34837239980826eba35ceb9a2f6fbc49698095b4 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 14 Oct 2026 04:58:32 +0000 Subject: [PATCH 4/6] fix(studio): restart the settle window when a busy request's frame goes away Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/studio/tests/e2e/edit-accuracy/case.mjs | 10 ++++++++-- .../studio/tests/e2e/edit-accuracy/settle.test.mjs | 14 +++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/studio/tests/e2e/edit-accuracy/case.mjs b/packages/studio/tests/e2e/edit-accuracy/case.mjs index b2638817ef8..529eb571640 100644 --- a/packages/studio/tests/e2e/edit-accuracy/case.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/case.mjs @@ -276,11 +276,17 @@ const unannounced = ({ dir, files, net }) => (f) => statSync(join(dir, f)).mtimeMs > Math.max(net.since, net.announced.get(f) ?? 0), ); -/** Whether the page is waiting on the network; a request whose frame is gone never finishes. */ +/** + * Whether the page is waiting on the network. A request whose frame is gone never finishes, so it is dropped, + * and counted as activity like a finished one: a window that began while it was in flight then restarts. + */ // fallow-ignore-next-line complexity export function networkBusy(ctx) { for (const request of ctx.net.inflight) - if (request.frame()?.detached !== false) ctx.net.inflight.delete(request); + if (request.frame()?.detached !== false) { + ctx.net.inflight.delete(request); + ctx.net.activity++; + } return { busy: ctx.net.inflight.size > 0 || unannounced(ctx), activity: ctx.net.activity }; } diff --git a/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs b/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs index 6c96c7f4542..49ba1ccf30b 100644 --- a/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs @@ -74,6 +74,18 @@ describe("settling on the page's network", () => { expect(heldStill(read(false, 0), 0, since - 500, 500)).toBe(true); }); + it("restarts a window that began busy once its request's frame is gone", () => { + const request = { frame: () => ({ detached: false }) }; + const net = { inflight: new Set([request]), activity: 0, announced: new Map(), since: Date.now() }; + const ctx = { dir: tmpdir(), files: [], net }; + const start = { ...read(false, 0), net: networkBusy(ctx) }; + expect(start.net.busy).toBe(true); + request.frame = () => null; + const now = { ...read(false, 0), net: networkBusy(ctx) }; + expect(now.net.busy).toBe(false); + expect(unsettledBy(start, now)).toBe(true); + }); + it("waits for requests in live frames and for the server to announce each written file", () => { const dir = mkdtempSync(join(tmpdir(), "edit-bench-net-")); try { @@ -89,7 +101,7 @@ describe("settling on the page's network", () => { since: since * 1000, }; const ctx = { dir, files: ["index.html"], net }; - expect(networkBusy(ctx)).toEqual({ busy: true, activity: 7 }); + expect(networkBusy(ctx)).toEqual({ busy: true, activity: 9 }); expect([...net.inflight]).toEqual([live]); net.inflight.clear(); expect(networkBusy(ctx).busy).toBe(false); From 5c7df21ff38c402fcf92fb1b709408f66df124fa Mon Sep 17 00:00:00 2001 From: James Date: Wed, 14 Oct 2026 05:00:49 +0000 Subject: [PATCH 5/6] style(studio): format the edit accuracy settle test Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/studio/tests/e2e/edit-accuracy/settle.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs b/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs index 49ba1ccf30b..1baf064a81a 100644 --- a/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs @@ -76,7 +76,12 @@ describe("settling on the page's network", () => { it("restarts a window that began busy once its request's frame is gone", () => { const request = { frame: () => ({ detached: false }) }; - const net = { inflight: new Set([request]), activity: 0, announced: new Map(), since: Date.now() }; + const net = { + inflight: new Set([request]), + activity: 0, + announced: new Map(), + since: Date.now(), + }; const ctx = { dir: tmpdir(), files: [], net }; const start = { ...read(false, 0), net: networkBusy(ctx) }; expect(start.net.busy).toBe(true); From 707f63aeb28ea6ca05bd7f123217bb2f05ddad01 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 14 Oct 2026 05:35:45 +0000 Subject: [PATCH 6/6] fix(studio): seek the edit bench only after Studio binds the timeline openStudio now polls every 50 ms, so after a reload its first studio_seek often lands before the runtime binds the timeline (Studio still reports duration 0, but the playhead moves). The bind then replays the playhead from near 0 (core init.ts progress(0.0001) then totalTime). A tween that starts on the playhead was already initialised by the early seek, so GSAP skips its first keyframe. The preview then holds a box no fresh seek or render shows. In resize-fromto-px-r0-nested-z100-on that box is 90 px off, and it persists until the next seek. Wait for studio_look to report a duration before seeking. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../studio/tests/e2e/edit-accuracy/case.mjs | 32 +++++++++++------- .../tests/e2e/edit-accuracy/settle.test.mjs | 33 ++++++++++++++++++- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/studio/tests/e2e/edit-accuracy/case.mjs b/packages/studio/tests/e2e/edit-accuracy/case.mjs index 529eb571640..c92562dba60 100644 --- a/packages/studio/tests/e2e/edit-accuracy/case.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/case.mjs @@ -356,26 +356,36 @@ export async function settled(ctx, timeout = 15_000) { return now.m; } -/** Ready once Studio's own seek tool reports the composition and the playhead landed. */ +const callTool = (page, name, args) => + page.evaluate((n, a) => window.__editBench.call(n, a), name, args).catch(String); + +/** + * Seeks to the playhead once Studio reports the composition's duration, and returns the landed seek. A seek + * before that is replayed by the runtime's timeline bind from near 0, which skips the first keyframe of a + * tween starting at the playhead: the preview then holds a box no fresh seek or render shows. + */ // fallow-ignore-next-line complexity -export async function openStudio(ctx) { - ctx.handles = null; - await ctx.page.waitForFunction(() => window.__editBench?.has("studio_seek"), { timeout: 90_000 }); - let seek = null; +export async function seekBound(ctx) { for (const deadline = Date.now() + 30_000; Date.now() < deadline; await sleep(50)) { - seek = await ctx.page - .evaluate((time) => window.__editBench.call("studio_seek", { time }), ctx.playhead) - .catch(String); + const look = await callTool(ctx.page, "studio_look", {}); + if (!(look?.ok && look.duration > 0)) continue; + const seek = await callTool(ctx.page, "studio_seek", { time: ctx.playhead }); if ( seek?.ok && seek.duration > 0 && seek.playhead === ctx.playhead && (await findTarget(ctx.page)) ) - break; - seek = null; + return seek; } - if (!seek) throw new Error("studio never reported a seekable composition"); + return null; +} + +/** Ready once Studio's own seek tool reports the composition and the playhead landed. */ +export async function openStudio(ctx) { + ctx.handles = null; + await ctx.page.waitForFunction(() => window.__editBench?.has("studio_seek"), { timeout: 90_000 }); + if (!(await seekBound(ctx))) throw new Error("studio never reported a seekable composition"); return settled(ctx); } diff --git a/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs b/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs index 1baf064a81a..ba2b65fe68a 100644 --- a/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs @@ -2,7 +2,14 @@ import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { STILL_FRAMES, heldStill, networkBusy, swapPending, unsettledBy } from "./case.mjs"; +import { + STILL_FRAMES, + heldStill, + networkBusy, + seekBound, + swapPending, + unsettledBy, +} from "./case.mjs"; import { heldSize } from "./sequences.mjs"; describe("settling on the shown preview", () => { @@ -120,6 +127,30 @@ describe("settling on the page's network", () => { }); }); +describe("seeking to the playhead after a reload", () => { + it("seeks only once Studio reports the composition's duration, so the timeline bind cannot replay it", async () => { + // Studio before its timeline bind: a seek still moves the playhead, but the duration reads 0. + const calls = []; + let looks = 0; + const studio = { + studio_look: () => ({ ok: true, duration: ++looks > 2 ? 4 : 0 }), + studio_seek: ({ time }) => ({ ok: true, playhead: time, duration: looks > 2 ? 4 : 0 }), + }; + globalThis.window = { + __editBench: { call: (name, args) => (calls.push(name), studio[name](args)) }, + }; + const host = { evaluate: async () => true, boundingBox: async () => ({ width: 1, height: 1 }) }; + const preview = { url: () => "/preview", $: async () => ({}), frameElement: async () => host }; + const page = { evaluate: async (fn, ...args) => fn(...args), frames: () => [preview] }; + try { + expect(await seekBound({ page, playhead: 2 })).toMatchObject({ playhead: 2, duration: 4 }); + expect(calls).toEqual(["studio_look", "studio_look", "studio_look", "studio_seek"]); + } finally { + delete globalThis.window; + } + }); +}); + describe("the size auto-record keys hold", () => { const keys = new Map([ [3, { width: 440, height: 294 }],