diff --git a/packages/studio/AGENTS.md b/packages/studio/AGENTS.md index 325b5c9aef1..baa1c245c0a 100644 --- a/packages/studio/AGENTS.md +++ b/packages/studio/AGENTS.md @@ -134,6 +134,14 @@ 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. +- 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 e2363ded2f9..c92562dba60 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 { @@ -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)); @@ -231,8 +231,64 @@ 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, 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); + ctx.net.activity++; + } + 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,55 +311,81 @@ 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; } -/** 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; - for (const deadline = Date.now() + 30_000; Date.now() < deadline; await sleep(250)) { - seek = await ctx.page - .evaluate((time) => window.__editBench.call("studio_seek", { time }), ctx.playhead) - .catch(String); +export async function seekBound(ctx) { + for (const deadline = Date.now() + 30_000; Date.now() < deadline; await sleep(50)) { + 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); } @@ -876,6 +958,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/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..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`; @@ -24,24 +25,31 @@ 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 }) }; 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; @@ -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,53 @@ 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 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 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/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/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 = { diff --git a/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs b/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs index 82f051e29b7..ba2b65fe68a 100644 --- a/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs +++ b/packages/studio/tests/e2e/edit-accuracy/settle.test.mjs @@ -1,5 +1,15 @@ +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, + seekBound, + swapPending, + unsettledBy, +} from "./case.mjs"; import { heldSize } from "./sequences.mjs"; describe("settling on the shown preview", () => { @@ -47,6 +57,100 @@ 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("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 { + 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: 9 }); + 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("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 }],