From cc41db28f0df8754a8c66f8d7e517b429807fcc2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 23:42:56 +0000 Subject: [PATCH 1/4] test(server): takeover tests for #20924 backdrop-filter snapshots Co-Authored-By: Claude Opus 5.5 --- .../src/preview/ServerBrowserPage.test.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 2491e6ac9a5fc..4f83a0deb319c 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -247,6 +247,94 @@ describe("server browser element refs", () => { }); }); +describe("server browser snapshot screenshot", () => { + let browser: Browser; + let context: BrowserContext; + + beforeAll(async () => { + // ServerBrowserContexts launches with these, and the capture scale depends on them. + browser = await chromium.launch({ + headless: true, + args: ["--disable-gpu", "--force-device-scale-factor=2"], + }); + }); + afterAll(async () => { + await browser?.close(); + }); + beforeEach(async () => { + context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + deviceScaleFactor: 2, + }); + }); + afterEach(async () => { + await context.close(); + }); + + // Blue on the left half and red on the right, so a mirrored left half shows up on the right. + const halves = (overlay: string) => ` + +
+
+
`; + + const screenshotOf = async (html: string) => { + const page = await context.newPage(); + await page.setContent(html); + const { screenshot } = await ServerBrowserPage.snapshot({ + page, + cdp: await context.newCDPSession(page), + renderScale: 2, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + }); + // The server has no DOM types, so the decoder runs as page source. + const points = [ + [0.15, 0.75], + [0.85, 0.25], + [0.85, 0.75], + ]; + const pixels = (await page.evaluate(`(async () => { + const bytes = Uint8Array.from(atob(${JSON.stringify(screenshot.data)}), (char) => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" })); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + context.drawImage(bitmap, 0, 0); + return ${JSON.stringify(points)}.map(([x, y]) => + Array.from(context.getImageData(x * bitmap.width, y * bitmap.height, 1, 1).data), + ); + })()`)) as Array<[number, number, number, number]>; + return { screenshot, pixels }; + }; + const colorOf = ([red, green, blue]: readonly [number, number, number, number]) => + blue - red > 100 ? "blue" : red - blue > 100 ? "red" : `rgb(${red}, ${green}, ${blue})`; + + it("captures a blurred backdrop-filter page as rendered, not mirrored", async () => { + const { screenshot, pixels } = await screenshotOf(halves("backdrop-filter: blur(4px);")); + + expect({ + size: [screenshot.width, screenshot.height], + colors: pixels.map(colorOf), + }).toEqual({ size: [1280, 800], colors: ["blue", "red", "red"] }); + }); + + it("captures a plain page at the shrunk size with its content in place", async () => { + const { screenshot, pixels } = await screenshotOf(halves("")); + + expect({ + size: [screenshot.width, screenshot.height], + colors: pixels.map(colorOf), + }).toEqual({ size: [1280, 800], colors: ["blue", "red", "red"] }); + }); +}); + describe("server browser drag", () => { it("fails the drag, not the server, when it times out while the cursor is still moving", async () => { const timedOut = new Error("locator.dragTo: Timeout 30000ms exceeded."); From fbed6253804aacda390d53b7d90f5386ec81cf44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 23:46:41 +0000 Subject: [PATCH 2/4] fix(server): snapshots of backdrop-filter pages show the page as rendered Chromium mirrors the top-left of the page into a backdrop-filter when Page.captureScreenshot shrinks the capture with a clip scale. Capture at full scale and shrink the PNG on a canvas in an isolated world instead. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 2 + apps/server/src/preview/ServerBrowser.ts | 10 +-- apps/server/src/preview/ServerBrowserPage.ts | 85 ++++++++++--------- 3 files changed, 50 insertions(+), 47 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 2b33f3b370804..6952d3064b568 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -95,6 +95,8 @@ function makeSession(frame?: ReturnType) { // A snapshot sizes its screenshot from the PNG header; viewers get JPEG frames as-is. return { data: (input as { format: string }).format === "png" ? PNG_HEADER : "ZnJhbWU=" }; } + // Snapshots shrink their screenshot in the page; the header stands in for the result. + if (method === "Runtime.callFunctionOn") return { result: { value: PNG_HEADER } }; if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; // Snapshots read password fields from their own isolated world in each frame. if (method === "Page.createIsolatedWorld") { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 3544cd172a048..2e14dc70cdcb1 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1479,10 +1479,9 @@ const make = Effect.gen(function* () { try { await encoder.evaluate(ServerBrowserPage.recordingEncoderScript(recordingFrameRate)); // Seed idle pages before returning so an immediate stop has a frame to encode. - const firstFrame = await ServerBrowserPage.captureViewport(tab.page, tab.cdp, { + const firstFrame = await ServerBrowserPage.captureViewport(tab.cdp, { format: "jpeg", quality: RECORDING_SCREENCAST.quality, - scale: 1, }); await encoder.evaluate( ([data, width]) => @@ -2347,14 +2346,9 @@ const make = Effect.gen(function* () { } broadcastControl(tab); pushFileChooser(tab); - // Full scale: a scaled capture would flash in every other viewer. const pushStill = async () => { const data = await withCaptureLock(tab, () => - ServerBrowserPage.captureViewport(tab.page, session, { - format: "jpeg", - quality, - scale: 1, - }), + ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }), ).catch(() => null); if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); }; diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 2355029258396..8a0015d043f43 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -428,8 +428,6 @@ const pngSize = (base64: string) => { return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) }; }; -type Viewport = { readonly width: number; readonly height: number }; - /** * A page's display ratio and CSS viewport, read in an isolated world since page script can * reassign `devicePixelRatio`, `innerWidth`, and `innerHeight` in its own, and its page zoom. @@ -456,13 +454,7 @@ const readPageMetrics = async (cdp: CDPSession) => { readonly width: number; readonly height: number; }; - return { - ratio, - width, - height, - zoom, - offset: { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom }, - }; + return { ratio, width, height, zoom }; } } throw new Error( @@ -470,41 +462,55 @@ const readPageMetrics = async (cdp: CDPSession) => { ); }; -// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets. +// Never pass a clip scale: Chromium mirrors the top-left of the page into a `backdrop-filter` +// in any capture it shrinks. export const captureViewport = async ( - page: Page, cdp: CDPSession, - options: { - readonly format: "png" | "jpeg"; - readonly quality?: number; - readonly scale: number; - readonly viewport?: Viewport; - readonly offset?: { readonly x: number; readonly y: number } | undefined; - }, + options: { readonly format: "png" | "jpeg"; readonly quality?: number }, ) => { - let clip; - if (options.scale < 1) { - const viewport = options.viewport ?? page.viewportSize() ?? { width: 1280, height: 800 }; - let offset = options.offset; - if (!offset) { - const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); - const zoom = cssVisualViewport.zoom ?? 1; - offset = { x: cssVisualViewport.pageX * zoom, y: cssVisualViewport.pageY * zoom }; - } - clip = { - ...offset, - ...viewport, - scale: options.scale, - }; - } const { data } = await cdp.send("Page.captureScreenshot", { format: options.format, ...(options.quality === undefined ? {} : { quality: options.quality }), - ...(clip ? { clip } : {}), }); return data; }; +/** 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)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" })); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + context.imageSmoothingQuality = "high"; + context.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + return canvas.toDataURL("image/png").slice("data:image/png;base64,".length); +}`; + +const shrinkPng = async (cdp: CDPSession, png: string, scale: number) => { + const size = pngSize(png); + const { frameTree } = await cdp.send("Page.getFrameTree"); + const { executionContextId } = await cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: "t3-preview-screenshot", + }); + const { result, exceptionDetails } = await cdp.send("Runtime.callFunctionOn", { + functionDeclaration: SHRINK_PNG, + executionContextId, + arguments: [ + { value: png }, + { value: Math.round(size.width * scale) }, + { value: Math.round(size.height * scale) }, + ], + awaitPromise: true, + returnByValue: true, + }); + if (exceptionDetails) throw new Error("The snapshot could not shrink its screenshot."); + return result.value as string; +}; + type SnapshotInput = { readonly page: Page; readonly cdp: CDPSession; @@ -515,17 +521,16 @@ type SnapshotInput = { readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"]; }; -/** The scale a page renders at and its viewport in device-independent pixels, a clip's units. */ +/** The scale a page renders at and its viewport in device-independent pixels. */ const renderingOf = async (input: SnapshotInput) => { if (input.renderScale !== "display") { const viewport = input.page.viewportSize() ?? { width: 1280, height: 800 }; - return { renderScale: input.renderScale, viewport, offset: undefined }; + return { renderScale: input.renderScale, viewport }; } // The display ratio a desktop page reports includes its page zoom, and its sizes are CSS pixels. const page = await readPageMetrics(input.cdp); return { renderScale: page.ratio / page.zoom, - offset: page.offset, viewport: { width: Math.round(page.width * page.zoom), height: Math.round(page.height * page.zoom), @@ -537,7 +542,7 @@ const capture = async ( input: SnapshotInput, deadline: number, ): Promise => { - const { renderScale, viewport, offset } = await renderingOf(input); + const { renderScale, viewport } = await renderingOf(input); // A late metrics reply must not begin a capture after viewers have resumed. if (Date.now() >= deadline) throw tooBusy(); const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * renderScale)); @@ -555,7 +560,9 @@ const capture = async ( "url" | "title" | "loading" | "visibleText" | "interactiveElements" > >, - captureViewport(input.page, input.cdp, { format: "png", scale, viewport, offset }), + captureViewport(input.cdp, { format: "png" }).then((png) => + scale < 1 ? shrinkPng(input.cdp, png, scale) : png, + ), ]); void pageAndScreenshot.catch(constVoid); // Leaves time for the second read; a frame that never loads holds the tree until then. From 097dc0d6c0e8441d1b2aa085d011a0e509ef26d9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 23:47:20 +0000 Subject: [PATCH 3/4] chore(server): remove outdated scaled capture comment --- apps/server/src/preview/ServerBrowser.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 2e14dc70cdcb1..447b17e4fde7b 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1533,7 +1533,6 @@ const make = Effect.gen(function* () { return started; }; - // Scaled captures repaint every screencast; pause them to avoid leaking that frame. const withScreencastsPaused = (tab: ServerTab, capture: () => Promise): Promise => withCaptureLock(tab, async () => { tab.capturing += 1; From f8587a5d2a6e2e75ffa1fac5b9986cf5c132578a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 23:52:41 +0000 Subject: [PATCH 4/4] fix(server): snapshots of XML documents shrink their screenshot again Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowserPage.test.ts | 15 +++++++++++++++ apps/server/src/preview/ServerBrowserPage.ts | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 4f83a0deb319c..b5e0939166064 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -333,6 +333,21 @@ describe("server browser snapshot screenshot", () => { colors: pixels.map(colorOf), }).toEqual({ size: [1280, 800], colors: ["blue", "red", "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,"); + const { screenshot } = await ServerBrowserPage.snapshot({ + page, + cdp: await context.newCDPSession(page), + renderScale: 2, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + }); + + expect([screenshot.width, screenshot.height]).toEqual([1280, 800]); + }); }); describe("server browser drag", () => { diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 8a0015d043f43..369b85b281d54 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -479,7 +479,8 @@ export const captureViewport = async ( const SHRINK_PNG = `async function (png, width, height) { const bytes = Uint8Array.from(atob(png), (char) => char.charCodeAt(0)); const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/png" })); - const canvas = document.createElement("canvas"); + // Namespaced, as an XML document's createElement makes a plain element with no getContext. + const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d");