diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index dd1895c9b7361..cabc2b99e67ba 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -19,9 +19,54 @@ import { } from "vite-plus/test"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; +import { SessionControl } from "./SessionControl.ts"; const decodeEvaluateInput = Schema.decodeUnknownSync(PreviewAutomationEvaluateInput); +describe("server browser capture deadlines", () => { + it("releases control after an unanswered capture and ignores its late reply", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const captured = Promise.withResolvers<{ data: string }>(); + const send = vi.fn(async (method: string) => + method === "Page.captureScreenshot" + ? captured.promise + : { result: { value: "still responsive" } }, + ); + const cdp = { send } as unknown as CDPSession; + const control = new SessionControl("agent"); + let outcome: unknown; + const capture = control + .agent("agent", () => ServerBrowserPage.captureViewport(cdp, { format: "png" })) + .catch((cause: unknown) => { + outcome = cause; + }); + const evaluation = control.agent("agent", () => + ServerBrowserPage.evaluate(cdp, { expression: "document.title" }, 1_000), + ); + + await vi.advanceTimersByTimeAsync(3_000); + expect(outcome).toMatchObject({ + tag: "PreviewAutomationExecutionError", + message: expect.stringContaining("not producing screenshot frames"), + }); + await capture; + expect(await evaluation).toBe("still responsive"); + expect(vi.getTimerCount()).toBe(0); + + captured.resolve({ data: "late-frame" }); + await captured.promise; + expect(outcome).toBeInstanceOf(ServerBrowserPage.ServerBrowserOperationError); + expect(send.mock.calls.map(([method]) => method)).toEqual([ + "Page.captureScreenshot", + "Runtime.evaluate", + ]); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("server browser element refs", () => { let browser: Browser; let context: BrowserContext; diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 9df6c539014c5..0d5ed3d1ff893 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -29,6 +29,8 @@ const isBrowserControlInterrupted = Schema.is(BrowserControlInterrupted); const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_SCREENSHOT_WIDTH = 1280; +// Two attempts must fit in a snapshot's 10-second deadline. +const SCREENSHOT_TIMEOUT_MS = 3_000; const WAIT_POLL_MS = 100; export const DIAGNOSTIC_BUFFER_LIMIT = 200; @@ -468,10 +470,27 @@ export const captureViewport = async ( cdp: CDPSession, options: { readonly format: "png" | "jpeg"; readonly quality?: number }, ) => { - const { data } = await cdp.send("Page.captureScreenshot", { + const capture = 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( + new ServerBrowserOperationError({ + tag: "PreviewAutomationExecutionError", + message: + "The browser is not producing screenshot frames. For a desktop tab, show or restore the T3 Code window and try again.", + }), + ), + SCREENSHOT_TIMEOUT_MS, + ); + }); + // Hidden native guests can leave CDP captures pending forever. This deadline also + // releases a viewer's still-capture lock, which an agent snapshot may be waiting for. + const { data } = await Promise.race([capture, expired]).finally(() => clearTimeout(timer)); return data; };