Files
hyperframes/packages/studio/tests/e2e/edit-accuracy/render.test.mjs
James RussoandClaude Opus 5.5 d9edffa3a4 perf(studio): settle the edit accuracy bench on frames and network, and reuse the producer Chrome (#5571)
* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* test(studio): import render.mjs at the top of the edit accuracy render test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(studio): restart the settle window when a busy request's frame goes away

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* style(studio): format the edit accuracy settle test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:52:14 +00:00

135 lines
5.1 KiB
JavaScript

import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
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`;
// Chrome's Fetch urlPattern: the whole URL, `*` any run of characters, `?` one character.
const chromeUrlPattern = (pattern) =>
new RegExp(
`^${pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")}$`,
);
const sent = [];
const handlers = {};
const sessionArgs = [];
const onNewDocument = [];
const page = {
evaluateOnNewDocument: async (fn) => void onNewDocument.push(fn),
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);
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 (session) => void closed.push({ ...session }),
}));
const decoder = { evaluate: async () => ({ left: 0, right: 1, top: 0, bottom: 1, area: 1 }) };
async function render(html) {
const dir = mkdtempSync(join(tmpdir(), "edit-bench-render-"));
if (html) writeFileSync(join(dir, "index.html"), html);
onNewDocument.length = 0;
try {
await renderBox(dir, decoder);
} finally {
rmSync(dir, { recursive: true });
}
return onNewDocument.map((fn) => fn.name);
}
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([
"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[2].params.body).toBe(gsap);
expect(sessionArgs[0][4]).toMatchObject({ staticFrameDedup: false });
});
it("opts a project with no timeline out of the engine's timeline wait, and only such a project", async () => {
expect(await render("<div data-composition-id='main'></div>")).toEqual(["markNoTimeline"]);
expect(await render("<script>window.__timelines = {};</script>")).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 });
}
});
});