From aaea59fd9240751a4a5ee354f8b8cf758987574a Mon Sep 17 00:00:00 2001 From: watashiwaaniket Date: Sat, 17 Oct 2026 20:28:02 +0530 Subject: [PATCH] fix(server): preview snapshots no longer mirror pages behind a blurred backdrop Chromium mirrors the top-left of the page into a backdrop-filter in any capture it shrinks, so snapshots of tabs wider than 640 CSS px showed a tiled, mirrored backdrop behind dialogs. Snapshots now capture at full scale and shrink the finished PNG in a scratch page's canvas. Fixes #20899 Co-authored-by: Cursor --- apps/server/src/preview/ServerBrowser.test.ts | 56 ++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 26 +++++-- .../src/preview/ServerBrowserPage.test.ts | 70 +++++++++++++++++++ apps/server/src/preview/ServerBrowserPage.ts | 70 +++++++++++++------ 4 files changed, 191 insertions(+), 31 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 952cf23a735b..2410baa7a5c7 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -46,7 +46,13 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ return context as unknown as BrowserContext; } async scratchPage() { - return makeContext().page as unknown as Page; + const scratch = makeContext(); + scratchContexts.push(scratch); + scratch.page.evaluate.mockImplementation(async () => { + if (scratchResult instanceof Error) throw scratchResult; + return scratchResult as never; + }); + return scratch.page as unknown as Page; } async connectDesktopPage(endpoint: string) { const context = makeContext(); @@ -181,6 +187,8 @@ function makeContext(onClose?: (context: BrowserContext) => void) { } const contexts: ReturnType[] = []; +const scratchContexts: ReturnType[] = []; +let scratchResult: unknown = "c21hbGw="; const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; let contextGate: PromiseWithResolvers | null = null; let pageCopy = ""; @@ -294,6 +302,8 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({ beforeEach(() => { contexts.length = 0; + scratchContexts.length = 0; + scratchResult = "c21hbGw="; contextRequests.length = 0; contextGate = null; contextFailure = null; @@ -480,6 +490,50 @@ it.live("enforces provider ownership and explicit targets when a session has mul ).pipe(Effect.provide(layer)), ); +it.live("a snapshot captures at full scale and shrinks the image in a scratch page", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const { screenshot } = yield* broker.invoke({ + scope, + tabId, + operation: "snapshot", + input: {}, + }); + + const captures = contexts[0]!.sessions + .flatMap((session) => session.send.mock.calls) + .filter(([method]) => method === "Page.captureScreenshot"); + expect(captures).toHaveLength(1); + expect(captures[0]![1]).not.toHaveProperty("clip"); + expect(screenshot).toEqual({ + mimeType: "image/png", + data: "c21hbGw=", + width: 1280, + height: 800, + }); + expect(scratchContexts).toHaveLength(1); + expect(scratchContexts[0]!.page.close).toHaveBeenCalledTimes(1); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a snapshot that cannot shrink its image still closes the scratch page", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + scratchResult = new Error("scratch page crashed"); + const failure = yield* broker + .invoke({ scope, tabId, operation: "snapshot", input: {} }) + .pipe(Effect.flip); + + expect(JSON.stringify(failure)).toContain("scratch page crashed"); + expect(scratchContexts.length).toBeGreaterThan(0); + for (const scratch of scratchContexts) expect(scratch.page.close).toHaveBeenCalledTimes(1); + }), + ).pipe(Effect.provide(layer)), +); + it.live("streams to a read-only viewer without allowing takeover, input, or viewport changes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 5157574f9004..5fd7d1eecdee 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1431,10 +1431,9 @@ const make = Effect.gen(function* () { try { await encoder.evaluate(ServerBrowserPage.RECORDING_ENCODER_SCRIPT); // 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]) => @@ -1486,7 +1485,18 @@ const make = Effect.gen(function* () { return started; }; - // Scaled captures repaint every screencast; pause them to avoid leaking that frame. + const downscaleScreenshot = async ( + png: string, + size: { readonly width: number; readonly height: number }, + ) => { + const scratch = await contexts.scratchPage(); + try { + return await ServerBrowserPage.downscalePng(scratch, png, size); + } finally { + await scratch.close().catch(constVoid); + } + }; + const withScreencastsPaused = (tab: ServerTab, capture: () => Promise): Promise => withCaptureLock(tab, async () => { tab.capturing += 1; @@ -1895,7 +1905,11 @@ const make = Effect.gen(function* () { } case "snapshot": { return withScreencastsPaused(tab, () => - ServerBrowserPage.snapshot({ ...tab, renderScale: RENDER_SCALE }), + ServerBrowserPage.snapshot({ + ...tab, + renderScale: RENDER_SCALE, + downscale: downscaleScreenshot, + }), ); } case "click": { @@ -2258,13 +2272,11 @@ 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, { + ServerBrowserPage.captureViewport(session, { format: "jpeg", quality, - scale: 1, }), ).catch(() => null); if (data) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 1fbf1bbf1546..54cae9c040ba 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -36,6 +36,7 @@ describe("server browser element refs", () => { page, cdp, renderScale: 1, + downscale: () => Promise.reject(new Error("a full-scale capture is never shrunk")), consoleEntries: [], networkEntries: [], actionTimeline: [], @@ -331,6 +332,75 @@ describe("server browser element refs", () => { }); }); +describe("server browser snapshot screenshot", () => { + let browser: Browser; + + beforeAll(async () => { + // The launch flags of ServerBrowserContexts: the capture scale arithmetic assumes them. + browser = await chromium.launch({ + headless: true, + args: ["--disable-gpu", "--force-device-scale-factor=2"], + }); + }); + afterAll(async () => { + await browser?.close(); + }); + + const pixelAt = (scratch: Page, png: string, x: number, y: number) => + scratch.evaluate(`(async () => { + const bytes = Uint8Array.from(atob(${JSON.stringify(png)}), (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 Array.from(context.getImageData(${x}, ${y}, 1, 1).data); + })()`) as Promise<[number, number, number, number]>; + + it("draws a backdrop-filter once when the viewport is wider than 640 CSS px", async () => { + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + deviceScaleFactor: 2, + }); + const scratchContext = await browser.newContext(); + try { + const scratch = await scratchContext.newPage(); + const page = await context.newPage(); + // Chromium mirrors the top-left quarter of the page into a backdrop it captured shrunk, so + // the red block on the right edge vanishes from behind the overlay. + await page.setContent(` + +
`); + const { screenshot } = await ServerBrowserPage.snapshot({ + page, + cdp: await context.newCDPSession(page), + renderScale: 2, + downscale: (png, size) => ServerBrowserPage.downscalePng(scratch, png, size), + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + }); + + const png = Buffer.from(screenshot.data, "base64"); + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([ + screenshot.width, + screenshot.height, + ]); + expect([screenshot.width, screenshot.height]).toEqual([1280, 800]); + const [red, green] = await pixelAt(scratch, screenshot.data, 1100, 200); + expect(red - green).toBeGreaterThan(100); + } finally { + await context.close(); + await scratchContext.close(); + } + }); +}); + 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."); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 908de1b6c48b..3dc21c7f69d9 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -373,27 +373,15 @@ const maskTree = (tree: string, dotted: RegExp) => }) .join("\n"); -// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets. +// Never pass a clip scale below 1: Chromium mirrors the top-left of the page into a +// `backdrop-filter` in any capture it shrinks. Shrink the finished image instead. export const captureViewport = async ( - page: Page, cdp: CDPSession, - options: { readonly format: "png" | "jpeg"; readonly quality?: number; readonly scale: number }, + options: { readonly format: "png" | "jpeg"; readonly quality?: number }, ) => { - let clip; - if (options.scale < 1) { - const viewport = page.viewportSize() ?? { width: 1280, height: 800 }; - const { cssVisualViewport } = await cdp.send("Page.getLayoutMetrics"); - clip = { - x: cssVisualViewport.pageX, - y: cssVisualViewport.pageY, - ...viewport, - scale: options.scale, - }; - } const { data } = await cdp.send("Page.captureScreenshot", { format: options.format, ...(options.quality === undefined ? {} : { quality: options.quality }), - ...(clip ? { clip } : {}), }); return data; }; @@ -402,6 +390,10 @@ type SnapshotInput = { readonly page: Page; readonly cdp: CDPSession; readonly renderScale: number; + readonly downscale: ( + png: string, + size: { readonly width: number; readonly height: number }, + ) => Promise; readonly consoleEntries: ReadonlyArray; readonly networkEntries: ReadonlyArray; readonly actionTimeline: PreviewAutomationSnapshot["actionTimeline"]; @@ -427,7 +419,7 @@ const capture = async ( "url" | "title" | "loading" | "visibleText" | "interactiveElements" > >, - captureViewport(input.page, input.cdp, { format: "png", scale }), + captureViewport(input.cdp, { format: "png" }), ]); void pageAndScreenshot.catch(constVoid); // Leaves time for the second read; a frame that never loads holds the tree until then. @@ -452,7 +444,13 @@ const capture = async ( void iframe.dispose().catch(constVoid); if (frame && unread.includes(frame)) throw tooBusy(); } - const [page, data] = await pageAndScreenshot; + const [page, screenshot] = await pageAndScreenshot; + const size = { + width: Math.round(viewport.width * input.renderScale * scale), + height: Math.round(viewport.height * input.renderScale * scale), + }; + // Before the generation check, which guards the synchronous ref registration below. + const data = scale < 1 ? await input.downscale(screenshot, size) : screenshot; if (state.generation !== generation) throw pageChanged(); const mask = (text: string) => (dotted ? text.replace(dotted, MASKED_VALUE) : text); const accessibilityTree = (dotted ? maskTree(tree, dotted) : tree) @@ -471,12 +469,7 @@ const capture = async ( consoleEntries: [...input.consoleEntries], networkEntries: [...input.networkEntries], actionTimeline: [...input.actionTimeline], - screenshot: { - mimeType: "image/png", - data, - width: Math.round(viewport.width * input.renderScale * scale), - height: Math.round(viewport.height * input.renderScale * scale), - }, + screenshot: { mimeType: "image/png", data, ...size }, }; }; @@ -793,6 +786,37 @@ export const parseNetError = (errorText: string) => { return { description, code: NET_ERROR_CODES[description] ?? -2 }; }; +const DOWNSCALE_SCRIPT = `window.__t3Downscale = async (base64, width, height) => { + const bytes = Uint8Array.from(atob(base64), (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); +};`; + +interface DownscaleWindow { + __t3Downscale(base64: string, width: number, height: number): Promise; +} + +/** The server has no image library, so a scratch page's canvas does the resize. */ +export const downscalePng = async ( + scratch: Page, + png: string, + size: { readonly width: number; readonly height: number }, +) => { + await scratch.evaluate(DOWNSCALE_SCRIPT); + return scratch.evaluate( + ([data, width, height]) => + (globalThis as unknown as DownscaleWindow).__t3Downscale(data, width, height), + [png, size.width, size.height] as const, + ); +}; + export const RECORDING_ENCODER_SCRIPT = `(() => { const canvas = document.createElement("canvas"); const context = canvas.getContext("2d");