diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 31f180bcc923c..7812b6a3a952c 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -5,7 +5,16 @@ import { type CDPSession, type Page, } from "playwright-core"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; @@ -388,3 +397,78 @@ describe("server browser drag", () => { } }); }); + +describe("server browser capture", () => { + it("fails a capture the page never answers instead of waiting for it", async () => { + vi.useFakeTimers(); + try { + const cdp = { send: () => new Promise(() => {}) } as unknown as CDPSession; + const capture = ServerBrowserPage.captureViewport(cdp, { format: "jpeg" }).then( + () => null, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(5_000); + expect(await capture).toMatchObject({ tag: "PreviewAutomationTimeoutError" }); + } finally { + vi.useRealTimers(); + } + }); + + it("sends no new screenshot while an earlier one is unanswered", async () => { + vi.useFakeTimers(); + try { + const firstAnswer = Promise.withResolvers<{ data: string }>(); + const send = vi + .fn() + .mockReturnValueOnce(firstAnswer.promise) + .mockResolvedValue({ data: "second" }); + const cdp = { send } as unknown as CDPSession; + const outcome = (capture: Promise) => + capture.then( + (data) => data, + (error: unknown) => error, + ); + + const first = outcome(ServerBrowserPage.captureViewport(cdp, { format: "jpeg" })); + await vi.advanceTimersByTimeAsync(5_000); + expect(await first).toMatchObject({ tag: "PreviewAutomationTimeoutError" }); + + const blocked = outcome(ServerBrowserPage.captureViewport(cdp, { format: "jpeg" })); + await vi.advanceTimersByTimeAsync(5_000); + expect(await blocked).toMatchObject({ tag: "PreviewAutomationTimeoutError" }); + expect(send).toHaveBeenCalledTimes(1); + + firstAnswer.resolve({ data: "first" }); + await vi.advanceTimersByTimeAsync(0); + expect(await ServerBrowserPage.captureViewport(cdp, { format: "jpeg" })).toBe("second"); + expect(send).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("lets only one waiting capture send once the unanswered one settles", async () => { + vi.useFakeTimers(); + try { + const firstAnswer = Promise.withResolvers<{ data: string }>(); + const send = vi + .fn() + .mockReturnValueOnce(firstAnswer.promise) + .mockReturnValue(new Promise(() => {})); + const cdp = { send } as unknown as CDPSession; + const captures = [1, 2, 3].map(() => + ServerBrowserPage.captureViewport(cdp, { format: "jpeg" }).catch(() => null), + ); + + firstAnswer.resolve({ data: "first" }); + await vi.advanceTimersByTimeAsync(0); + expect(send).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(5_000); + await Promise.all(captures); + expect(send).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 9525ac7c5b579..46a0d199512f1 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -206,6 +206,8 @@ const withoutTreeDataPayloads = (tree: string) => // The broker waits 15s for the host; a snapshot, retry included, answers well before. const SNAPSHOT_TIMEOUT_MS = 10_000; const FRAME_ANSWER_MS = 1_000; +// A tab that draws no frames never answers a screenshot, and callers hold the tab's queue while waiting. +const CAPTURE_TIMEOUT_MS = 5_000; // Include password and CSS-masked text in open shadow roots, normalized like accessible text. const DOTTED_SCRIPT = `(() => { @@ -238,6 +240,12 @@ const tooBusy = () => "The page was too busy to capture a snapshot in time. Wait for it to settle and take another snapshot.", ); +const noFrame = () => + new ServerBrowserOperationError( + "PreviewAutomationTimeoutError", + "The page drew no frame to capture in time. Try again once the tab is showing or has settled.", + ); + const VALUE_TOO_LONG = "A password field holds a value too long to hide, so no snapshot was taken."; /** Reuse one CDP session per out-of-process frame across snapshots. */ @@ -444,17 +452,41 @@ const readPageMetrics = async (cdp: CDPSession) => { ); }; +// CDP cannot cancel a screenshot, so a session keeps at most one unanswered. +const unansweredCaptures = new WeakMap>(); + // 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 ( cdp: CDPSession, options: { readonly format: "png" | "jpeg"; readonly quality?: number }, ) => { - const { data } = await cdp.send("Page.captureScreenshot", { - format: options.format, - ...(options.quality === undefined ? {} : { quality: options.quality }), + let timer: ReturnType | undefined; + const expired = new Promise((_, reject) => { + timer = setTimeout(() => reject(noFrame()), CAPTURE_TIMEOUT_MS); }); - return data; + try { + // Callers waiting on the same capture wake together; only the first may send. + for ( + let unanswered = unansweredCaptures.get(cdp); + unanswered; + unanswered = unansweredCaptures.get(cdp) + ) { + await Promise.race([unanswered, expired]); + } + const captured = cdp.send("Page.captureScreenshot", { + format: options.format, + ...(options.quality === undefined ? {} : { quality: options.quality }), + }); + const settled = captured.then(constVoid, constVoid); + unansweredCaptures.set(cdp, settled); + void settled.then(() => { + if (unansweredCaptures.get(cdp) === settled) unansweredCaptures.delete(cdp); + }); + return (await Promise.race([captured, expired])).data; + } finally { + clearTimeout(timer); + } }; /** Runs in the page's own isolated world, as the server has no image library. */