diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index b87bc8d9ca4d8..5b0f47d989645 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -437,6 +437,122 @@ it.live("preview_open reports its navigation timeout instead of the request budg ).pipe(Effect.provide(layer)), ); +it.effect("an unanswered viewer still releases the capture lock and same-tab automation", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const context = contexts[0]!; + const captured = Promise.withResolvers>(); + const captureStarted = Promise.withResolvers(); + const newSession = context.newCDPSession; + context.newCDPSession = async (target) => { + const session = await newSession(target); + const send = session.send.getMockImplementation()!; + session.send.mockImplementation((method, input) => { + if (method === "Page.captureScreenshot") { + captureStarted.resolve(); + return captured.promise; + } + return send(method, input); + }); + return session; + }; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const attaching = yield* browser + .attachViewer(viewerInput(tabId, true)) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => captureStarted.promise); + const snapshotting = yield* broker + .invoke({ scope, tabId, operation: "snapshot", input: {} }) + .pipe(Effect.forkScoped); + const evaluating = yield* broker + .invoke({ scope, tabId, operation: "evaluate", input: { expression: "document.title" } }) + .pipe(Effect.forkScoped); + + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(3_000)); + const viewer = yield* Fiber.join(attaching); + expect((yield* Fiber.join(snapshotting)).screenshot?.data).toBe(PNG_HEADER); + expect(yield* Fiber.join(evaluating)).toBe("evaluated"); + const navigated = yield* broker.invoke({ + scope, + tabId, + operation: "navigate", + input: { url: "http://localhost:5173/recovered" }, + }); + expect(navigated.url).toBe("http://localhost:5173/recovered"); + + captured.resolve({ data: "late-frame" }); + yield* Effect.promise(() => captured.promise); + expect((yield* Queue.clear(viewer.output)).some((event) => event._tag === "frame")).toBe( + false, + ); + yield* viewer.input({ type: "takeControl" }); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.control?.owner).toBe("human"); + } finally { + vi.useRealTimers(); + } + }), + ).pipe(Effect.provide(layer)), +); + +it.effect("reports a stalled snapshot capture without losing the tab or its control queue", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const session = contexts[0]!.sessions[0]!; + const send = session.send.getMockImplementation()!; + const captureStarted = Promise.withResolvers(); + const captured = Promise.withResolvers>(); + session.send.mockImplementation((method, input) => { + if (method === "Page.captureScreenshot") { + captureStarted.resolve(); + return captured.promise; + } + return send(method, input); + }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const snapshotting = yield* broker + .invoke({ scope, tabId, operation: "snapshot", input: {} }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Effect.promise(() => captureStarted.promise); + const evaluating = yield* broker + .invoke({ scope, tabId, operation: "evaluate", input: { expression: "document.title" } }) + .pipe(Effect.forkScoped); + + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(6_000)); + const error = yield* Fiber.join(snapshotting); + expect(error).toMatchObject({ + _tag: "PreviewAutomationExecutionError", + message: expect.stringContaining("show or restore the T3 Code window"), + }); + expect( + session.send.mock.calls.filter(([method]) => method === "Page.captureScreenshot"), + ).toHaveLength(2); + expect(yield* Fiber.join(evaluating)).toBe("evaluated"); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status).toMatchObject({ tabId, available: true }); + expect(status.control?.ownedByCaller).toBe(true); + captured.resolve({ data: PNG_HEADER }); + } finally { + vi.useRealTimers(); + } + }), + ).pipe(Effect.provide(layer)), +); + it.live.each(["desktop" as const, undefined])( "explicit desktop targets ($0 runtime) fail without creating a server tab", (runtime) => diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 31f180bcc923c..f5467ff0ae62f 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -5,9 +5,77 @@ 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"; +import { SessionControl } from "./SessionControl.ts"; + +describe("server browser capture deadlines", () => { + it.each(["png", "jpeg"] as const)( + "releases control after an unanswered %s capture and ignores its late reply", + async (format) => { + 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 })) + .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(); + } + }, + ); + + it("clears the capture deadline when Chromium answers", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const cdp = { send: async () => ({ data: "current-frame" }) } as unknown as CDPSession; + expect(await ServerBrowserPage.captureViewport(cdp, { format: "png" })).toBe("current-frame"); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); describe("server browser element refs", () => { let browser: Browser; diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 9525ac7c5b579..ff8d8204cc9df 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -26,6 +26,8 @@ import { BrowserControlInterrupted } from "./SessionControl.ts"; const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_SCREENSHOT_WIDTH = 1280; +// Leave room for a snapshot retry before the broker's 15-second deadline. +const SCREENSHOT_TIMEOUT_MS = 3_000; const WAIT_POLL_MS = 100; export const DIAGNOSTIC_BUFFER_LIMIT = 200; @@ -450,10 +452,26 @@ 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( + "PreviewAutomationExecutionError", + "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; };