From 6e64358f94df00d86684da689ededd0de0cb2dea Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Tue, 10 Nov 2026 23:41:06 +0800 Subject: [PATCH 1/2] fix(server): keep the last page state in agent recordings Co-authored-by: Codex --- apps/server/src/preview/ServerBrowser.test.ts | 72 +++++++++++++++++-- apps/server/src/preview/ServerBrowser.ts | 19 +++++ apps/server/src/preview/ServerBrowserPage.ts | 3 + 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index c32947b30b995..06eb583344a05 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -256,31 +256,64 @@ function makeContext(onClose?: (context: BrowserContext) => void, initialUrl = " // Runs the real encoder script against fake canvas and MediaRecorder globals. function makeEncoderPage() { + let paintedFrame = ""; const canvas = { width: 0, height: 0, - getContext: () => ({ drawImage() {}, fillRect() {} }), - captureStream: (frameRate?: number) => ({ frameRate, getTracks: () => [] }), + getContext: () => ({ + drawImage(bitmap: { pixels: string }) { + paintedFrame = bitmap.pixels; + }, + fillRect() {}, + }), + captureStream: (frameRate?: number) => ({ + frameRate, + getTracks: () => [], + getVideoTracks: () => [{ requestFrame() {} }], + }), + }; + const video = { + play: async () => {}, + pause() {}, + requestVideoFrameCallback: (callback: () => void) => queueMicrotask(callback), }; class FakeMediaRecorder { static isTypeSupported = (type: string) => type === "video/webm"; + mimeType = "video/webm"; + state = "inactive"; onstart: (() => void) | null = null; + onstop: (() => void) | null = null; + ondataavailable: ((event: { data: Blob }) => void) | null = null; + readonly stream: { frameRate?: number }; constructor(stream: { frameRate?: number }, options: { videoBitsPerSecond: number }) { + this.stream = stream; encoderStreams.push({ frameRate: stream.frameRate, bitsPerSecond: options.videoBitsPerSecond, }); } start() { + this.state = "recording"; this.onstart?.(); } + stop() { + this.state = "inactive"; + this.ondataavailable?.({ data: new Blob([paintedFrame]) }); + this.onstop?.(); + } } const sandbox: Record = { - document: { createElement: () => canvas }, + document: { createElement: (tag: string) => (tag === "video" ? video : canvas) }, Path2D: function Path2D() {}, MediaRecorder: FakeMediaRecorder, - createImageBitmap: async () => ({ width: 1280, height: 800, close() {} }), + createImageBitmap: async (blob: Blob) => ({ + width: 1280, + height: 800, + pixels: await blob.text(), + close() {}, + }), atob, + btoa, Blob, performance, }; @@ -818,6 +851,37 @@ it.live.each([ ).pipe(Effect.provide(layer)), ); +it.live.each([ + { refreshFails: false, expected: "final frame" }, + { refreshFails: true, expected: "frame" }, +])( + "saves the last page state when stopping a recording ($refreshFails)", + ({ refreshFails, expected }) => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const fs = yield* FileSystem.FileSystem; + yield* broker.invoke({ scope, tabId, operation: "recordingStart", input: {} }); + + const cdp = contexts[0]!.sessions[0]!; + const send = cdp.send.getMockImplementation()!; + cdp.send.mockImplementation((method, input) => { + if (method !== "Page.captureScreenshot") return send(method, input); + return refreshFails + ? Promise.reject(new Error("Page cannot capture a frame")) + : Promise.resolve({ data: Buffer.from("final frame").toString("base64") }); + }); + const artifact = yield* broker.invoke<{ path: string }>({ + scope, + tabId, + operation: "recordingStop", + input: {}, + }); + expect(yield* fs.readFileString(artifact.path)).toBe(expected); + }), + ).pipe(Effect.provide(layer)), +); + it.live("enforces provider ownership and explicit targets when a session has multiple tabs", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index ee31b17cc5889..4a6369771bc6d 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1824,6 +1824,25 @@ const make = Effect.gen(function* () { await recording.session.send("Page.stopScreencast").catch(constVoid); await Promise.all(recording.framesInFlight); await recording.session.detach().catch(constVoid); + // A screencast can miss the last paint when the page goes idle. + if (!tab.closing && !tab.dialog) { + const finalFrame = await ServerBrowserPage.captureFinalRecordingFrame( + tab.cdp, + RECORDING_SCREENCAST.quality, + ); + if (finalFrame !== undefined) { + await recording.encoder + .evaluate( + ([data, width]) => + (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), + [ + finalFrame, + tab.page.viewportSize()?.width ?? UNATTACHED_FILL_VIEWPORT.width, + ] as const, + ) + .catch(constVoid); + } + } const stopped = await recording.encoder.evaluate(() => (globalThis as unknown as EncoderWindow).__t3Recorder.stop(), ); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 31cff821c6e42..90896312b235c 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -477,6 +477,9 @@ export const captureViewport = async ( return data; }; +export const captureFinalRecordingFrame = (cdp: CDPSession, quality: number) => + answered(captureViewport(cdp, { format: "jpeg", quality })); + /** Runs in the page's own isolated world, as the server has no image library. */ const SHRINK_PNG = `async function (png, width, height) { const bytes = Uint8Array.from(atob(png), (char) => char.charCodeAt(0)); From 5cab4e92fbe32ba5aea0a86e93b8b88a8b6f1607 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 23:43:12 +0000 Subject: [PATCH 2/2] fix(server): capture recordings after finite page transitions --- .../src/preview/ServerBrowserPage.test.ts | 35 +++++++++++++++++++ apps/server/src/preview/ServerBrowserPage.ts | 17 ++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 78f03f1f91b72..bbcb8c124ace4 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -430,6 +430,41 @@ describe("server browser snapshot screenshot", () => { }).toEqual({ size: [1280, 800], fullSize: [2560, 1600], colors: ["blue", "red", "red"] }); }); + it("records the destination of a finite entrance animation without waiting on infinite ones", async () => { + const page = await context.newPage(); + await page.setContent(` + + +
+
+ `); + await page.getByRole("button", { name: "Open" }).click(); + const frame = await ServerBrowserPage.captureFinalRecordingFrame( + await context.newCDPSession(page), + 80, + ); + expect(frame).toBeDefined(); + const pixel = (await page.evaluate(`(async () => { + const bytes = Uint8Array.from(atob(${JSON.stringify(frame)}), char => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" })); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + context.drawImage(bitmap, 0, 0); + return Array.from(context.getImageData(bitmap.width / 2, bitmap.height / 2, 1, 1).data); + })()`)) as [number, number, number, number]; + expect(colorOf(pixel)).toBe("red"); + }); + it("shrinks the screenshot of an XML document, whose createElement makes no canvas", async () => { const page = await context.newPage(); await page.goto("data:text/xml,"); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 90896312b235c..e0d03258c9ca0 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -478,7 +478,22 @@ export const captureViewport = async ( }; export const captureFinalRecordingFrame = (cdp: CDPSession, quality: number) => - answered(captureViewport(cdp, { format: "jpeg", quality })); + answered( + cdp + .send("Runtime.evaluate", { + // Capture the destination of finite transitions, not their initially transparent frame. + expression: `(async () => { + const paint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await paint(); + await Promise.allSettled(document.getAnimations() + .filter(animation => animation.playState === "running" && animation.effect?.getComputedTiming().iterations !== Infinity) + .map(animation => animation.finished)); + await paint(); + })()`, + awaitPromise: true, + }) + .then(() => captureViewport(cdp, { format: "jpeg", quality })), + ); /** Runs in the page's own isolated world, as the server has no image library. */ const SHRINK_PNG = `async function (png, width, height) {