From 839cd2335d0600dd6a03b5ad7163fe036f2db2e0 Mon Sep 17 00:00:00 2001 From: Oscar Laredo Date: Fri, 23 Oct 2026 23:27:21 +1000 Subject: [PATCH 1/3] fix(server): report the actual preview snapshot timeout --- .../src/mcp/PreviewAutomationBroker.test.ts | 37 +++++++++ .../server/src/mcp/PreviewAutomationBroker.ts | 12 ++- .../src/preview/ServerBrowserPage.test.ts | 63 ++++++++++++++- apps/server/src/preview/ServerBrowserPage.ts | 78 +++++++++++++------ packages/contracts/src/previewAutomation.ts | 18 ++++- 5 files changed, 181 insertions(+), 27 deletions(-) diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 7ae8bfbefead5..e904205291988 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -351,6 +351,43 @@ it.effect("distinguishes malformed remote failures", () => ), ); +it.effect("reports the capture deadline and pending stage instead of the request timeout", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const connected = yield* Deferred.make(); + const requests = requestsFrom( + (yield* broker.connect(makeHost())).pipe( + Stream.tap((event) => + event.type === "connected" ? Deferred.succeed(connected, undefined) : Effect.void, + ), + ), + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: false, + error: { + _tag: "PreviewAutomationTimeoutError", + message: "Snapshot capture did not complete.", + detail: { timeoutMs: 10_000, pendingStages: ["screenshot"] }, + }, + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(connected); + const error = yield* broker + .invoke({ scope, operation: "snapshot", input: {} }) + .pipe(Effect.flip); + expect(error).toMatchObject({ timeoutMs: 10_000, pendingStages: ["screenshot"] }); + expect(error.message).toBe( + "Preview automation snapshot timed out after 10000ms. Waiting for: screenshot.", + ); + }), + ), +); + it.effect("does not create host state from focus updates without a live stream", () => Effect.gen(function* () { const broker = yield* makeBroker; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index c1ae21dfd3b4c..9146a178f47cd 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -18,6 +18,7 @@ import { PreviewAutomationTabNotFoundError, PreviewAutomationTargetNotEditableError, PreviewAutomationTimeoutError, + PreviewAutomationTimeoutDetail, PreviewAutomationUnsupportedClientError, PreviewTabId, type PreviewAutomationError, @@ -206,6 +207,7 @@ function remoteDetailKind(detail: unknown): RemoteDetailKind { } const MAX_REASON_CHARS = 500; +const decodeTimeoutDetail = Schema.decodeUnknownOption(PreviewAutomationTimeoutDetail); const classifyResponseError = ( context: PreviewAutomationRequestErrorContext, @@ -253,11 +255,19 @@ const classifyResponseError = ( ...context, ...remoteDiagnostics, }); - case "PreviewAutomationTimeoutError": + case "PreviewAutomationTimeoutError": { + const detail = decodeTimeoutDetail(error.detail); return new PreviewAutomationTimeoutError({ ...context, ...remoteDiagnostics, + ...(Option.isSome(detail) + ? { + timeoutMs: Math.min(context.timeoutMs, detail.value.timeoutMs), + pendingStages: detail.value.pendingStages, + } + : {}), }); + } case "PreviewAutomationControlInterruptedError": { const reason = decodeControlReason(error.detail); return new PreviewAutomationControlInterruptedError({ diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 2491e6ac9a5fc..9ac6fa937b599 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -5,10 +5,71 @@ 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"; +describe("snapshot deadline diagnostics", () => { + it.each(["metrics", "screenshot"] as const)( + "identifies a stalled %s operation", + async (stage) => { + vi.useFakeTimers(); + try { + const frame = {}; + const page = { + viewportSize: () => ({ width: 1280, height: 800 }), + mainFrame: () => frame, + frames: () => [frame], + on: () => {}, + evaluate: async () => ({ + url: "about:blank", + title: "", + loading: false, + visibleText: "", + interactiveElements: [], + }), + ariaSnapshot: async () => "", + } as unknown as Page; + const cdp = { + send: async (method: string) => { + if (stage === "metrics" || method === "Page.captureScreenshot") + return new Promise(() => {}); + if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; + if (method === "Page.createIsolatedWorld") return { executionContextId: 1 }; + if (method === "Runtime.evaluate") return { result: { value: [] } }; + throw new Error(`Unexpected command: ${method}`); + }, + } as unknown as CDPSession; + const captured = ServerBrowserPage.snapshot({ + page, + cdp, + renderScale: stage === "metrics" ? "display" : 1, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + }).catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(10_000); + expect(await captured).toMatchObject({ + tag: "PreviewAutomationTimeoutError", + detail: { timeoutMs: 10_000, pendingStages: [stage] }, + }); + } 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 02089cca3205c..036581d9ca99e 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -13,6 +13,7 @@ import { type PreviewAutomationSelectInput, type PreviewAutomationSelectResult, type PreviewAutomationSnapshot, + type PreviewAutomationSnapshotStage, type PreviewAutomationTypeInput, type PreviewAutomationUploadInput, type PreviewAutomationWaitForInput, @@ -228,10 +229,13 @@ const pageChanged = () => "The page changed while capturing its snapshot. Take another snapshot.", ); -const tooBusy = () => +const snapshotTimedOut = ( + pendingStages: ReadonlyArray = [], +) => new ServerBrowserOperationError( "PreviewAutomationTimeoutError", - "The page was too busy to capture a snapshot in time. Wait for it to settle and take another snapshot.", + `Snapshot capture timed out after ${SNAPSHOT_TIMEOUT_MS}ms. Try the snapshot again.`, + { timeoutMs: SNAPSHOT_TIMEOUT_MS, pendingStages }, ); const VALUE_TOO_LONG = "A password field holds a value too long to hide, so no snapshot was taken."; @@ -535,35 +539,58 @@ const renderingOf = async (input: SnapshotInput) => { const capture = async ( input: SnapshotInput, deadline: number, + pending: Map, ): Promise => { - const { renderScale, viewport, offset } = await renderingOf(input); + const track = async ( + stage: typeof PreviewAutomationSnapshotStage.Type, + operation: () => Promise, + ) => { + // A retry can overlap the previous attempt's unanswered screenshot. + pending.set(stage, (pending.get(stage) ?? 0) + 1); + try { + return await operation(); + } finally { + const count = (pending.get(stage) ?? 1) - 1; + if (count === 0) pending.delete(stage); + else pending.set(stage, count); + } + }; + const { renderScale, viewport, offset } = await track("metrics", () => renderingOf(input)); // A late metrics reply must not begin a capture after viewers have resumed. - if (Date.now() >= deadline) throw tooBusy(); + if (Date.now() >= deadline) throw snapshotTimedOut(); const scale = Math.min(1, MAX_SCREENSHOT_WIDTH / (viewport.width * renderScale)); const state = refsFor(input.page); invalidateRefs(input.page); const generation = state.generation; - const { readers, read } = await openReaders(input.page, input.cdp); - const before = await read(); + const { readers, read } = await track("frames", () => openReaders(input.page, input.cdp)); + const before = await track("frames", () => read()); // Taken between the reads, so a value that changes during either fails the comparison. // Awaited after the tree: a capture that fails has finished its tree before a retry starts. const pageAndScreenshot = Promise.all([ - input.page.evaluate(SNAPSHOT_SCRIPT) as Promise< - Pick< - PreviewAutomationSnapshot, - "url" | "title" | "loading" | "visibleText" | "interactiveElements" - > - >, - captureViewport(input.page, input.cdp, { format: "png", scale, viewport, offset }), + track( + "page state", + () => + input.page.evaluate(SNAPSHOT_SCRIPT) as Promise< + Pick< + PreviewAutomationSnapshot, + "url" | "title" | "loading" | "visibleText" | "interactiveElements" + > + >, + ), + track("screenshot", () => + captureViewport(input.page, input.cdp, { format: "png", scale, viewport, offset }), + ), ]); void pageAndScreenshot.catch(constVoid); // Leaves time for the second read; a frame that never loads holds the tree until then. - const tree = await input.page.ariaSnapshot({ - mode: "ai", - boxes: true, - timeout: Math.max(1, deadline - Date.now() - 2_000), - }); - const after = await read(before); + const tree = await track("accessibility tree", () => + input.page.ariaSnapshot({ + mode: "ai", + boxes: true, + timeout: Math.max(1, deadline - Date.now() - 2_000), + }), + ); + const after = await track("frames", () => read(before)); // A value that changed meanwhile may be in the tree under neither reading. if (after.some((values, i) => values && JSON.stringify(values) !== JSON.stringify(before[i]))) throw pageChanged(); @@ -577,7 +604,7 @@ const capture = async ( if (!iframe) throw pageChanged(); const frame = await iframe.contentFrame(); void iframe.dispose().catch(constVoid); - if (frame && unread.includes(frame)) throw tooBusy(); + if (frame && unread.includes(frame)) throw snapshotTimedOut(); } const [page, data] = await pageAndScreenshot; if (state.generation !== generation) throw pageChanged(); @@ -614,26 +641,29 @@ const snapshotTurns = new WeakMap(); export const snapshot = async (input: SnapshotInput): Promise => { const deadline = Date.now() + SNAPSHOT_TIMEOUT_MS; + const pending = new Map(); let timer: ReturnType | undefined; const expired = new Promise((_, reject) => { - timer = setTimeout(() => reject(tooBusy()), SNAPSHOT_TIMEOUT_MS); + timer = setTimeout(() => reject(snapshotTimedOut([...pending.keys()])), SNAPSHOT_TIMEOUT_MS); }); const turn = (snapshotTurns.get(input.page) ?? 0) + 1; snapshotTurns.set(input.page, turn); // A page that changed mid-capture, like one rotating ads, has usually settled by a second // try. None starts late or after another snapshot began, since it would revoke the refs of // whichever snapshot came next. - const captured = capture(input, deadline).catch((cause: unknown) => + const captured = capture(input, deadline, pending).catch((cause: unknown) => Date.now() < deadline && snapshotTurns.get(input.page) === turn && !(cause instanceof Error && cause.message === VALUE_TOO_LONG) - ? capture(input, deadline) + ? capture(input, deadline, pending) : Promise.reject(cause), ); try { return await Promise.race([captured, expired]); } catch (cause) { - throw cause instanceof Error && cause.name === "TimeoutError" ? tooBusy() : cause; + throw cause instanceof Error && cause.name === "TimeoutError" + ? snapshotTimedOut([...pending.keys()]) + : cause; } finally { clearTimeout(timer); } diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 0e9ff94d6ffe5..5221a018c0b9e 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -941,16 +941,32 @@ export class PreviewAutomationTabNotFoundError extends Schema.TaggedError()( "PreviewAutomationTimeoutError", { ...PreviewAutomationRequestErrorFields, ...PreviewAutomationOptionalRemoteDiagnosticFields, + pendingStages: Schema.optional(PreviewAutomationTimeoutDetail.fields.pendingStages), }, ) { override get message(): string { const summary = `Preview automation ${this.operation} timed out after ${this.timeoutMs}ms.`; - return summary; + return this.pendingStages?.length + ? `${summary} Waiting for: ${this.pendingStages.join(", ")}.` + : summary; } } From 60aa61dbf24cdb2471dea4f3e5b281bc2b0f8f5f Mon Sep 17 00:00:00 2001 From: Oscar Laredo Date: Fri, 23 Oct 2026 23:41:26 +1000 Subject: [PATCH 2/3] fix(server): report unanswered frames and timed-out stages accurately --- .../src/preview/ServerBrowserPage.test.ts | 137 ++++++++++++------ apps/server/src/preview/ServerBrowserPage.ts | 63 +++++--- 2 files changed, 134 insertions(+), 66 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 9ac6fa937b599..8c10dd4e318b2 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -19,55 +19,104 @@ import { import * as ServerBrowserPage from "./ServerBrowserPage.ts"; describe("snapshot deadline diagnostics", () => { + const never = () => new Promise(() => {}); + const stalledSnapshot = async (options: { + readonly stalled?: "metrics" | "screenshot"; + readonly page?: Partial>; + readonly advanceMs: number; + }) => { + vi.useFakeTimers(); + try { + const frame = {}; + const page = { + viewportSize: () => ({ width: 1280, height: 800 }), + mainFrame: () => frame, + frames: () => [frame], + on: () => {}, + evaluate: async () => ({ + url: "about:blank", + title: "", + loading: false, + visibleText: "", + interactiveElements: [], + }), + ariaSnapshot: async () => "", + ...options.page, + } as unknown as Page; + const cdp = { + send: async (method: string) => { + if (options.stalled === "metrics" || method === "Page.captureScreenshot") return never(); + if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; + if (method === "Page.createIsolatedWorld") return { executionContextId: 1 }; + if (method === "Runtime.evaluate") return { result: { value: [] } }; + throw new Error(`Unexpected command: ${method}`); + }, + } as unknown as CDPSession; + const captured = ServerBrowserPage.snapshot({ + page, + cdp, + renderScale: options.stalled === "metrics" ? "display" : 1, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + }).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(options.advanceMs); + return await captured; + } finally { + vi.useRealTimers(); + } + }; + it.each(["metrics", "screenshot"] as const)( "identifies a stalled %s operation", async (stage) => { - vi.useFakeTimers(); - try { - const frame = {}; - const page = { - viewportSize: () => ({ width: 1280, height: 800 }), - mainFrame: () => frame, - frames: () => [frame], - on: () => {}, - evaluate: async () => ({ - url: "about:blank", - title: "", - loading: false, - visibleText: "", - interactiveElements: [], - }), - ariaSnapshot: async () => "", - } as unknown as Page; - const cdp = { - send: async (method: string) => { - if (stage === "metrics" || method === "Page.captureScreenshot") - return new Promise(() => {}); - if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; - if (method === "Page.createIsolatedWorld") return { executionContextId: 1 }; - if (method === "Runtime.evaluate") return { result: { value: [] } }; - throw new Error(`Unexpected command: ${method}`); - }, - } as unknown as CDPSession; - const captured = ServerBrowserPage.snapshot({ - page, - cdp, - renderScale: stage === "metrics" ? "display" : 1, - consoleEntries: [], - networkEntries: [], - actionTimeline: [], - }).catch((error: unknown) => error); - - await vi.advanceTimersByTimeAsync(10_000); - expect(await captured).toMatchObject({ - tag: "PreviewAutomationTimeoutError", - detail: { timeoutMs: 10_000, pendingStages: [stage] }, - }); - } finally { - vi.useRealTimers(); - } + expect(await stalledSnapshot({ stalled: stage, advanceMs: 10_000 })).toMatchObject({ + tag: "PreviewAutomationTimeoutError", + detail: { timeoutMs: 10_000, pendingStages: [stage] }, + }); }, ); + + it("keeps an accessibility tree that hit its own timeout", async () => { + const timedOut = Object.assign(new Error("Timeout exceeded"), { name: "TimeoutError" }); + const captured = await stalledSnapshot({ + page: { ariaSnapshot: () => Promise.reject(timedOut) }, + advanceMs: 0, + }); + expect(captured).toMatchObject({ + tag: "PreviewAutomationTimeoutError", + detail: { timeoutMs: 10_000 }, + }); + expect((captured as ServerBrowserPage.ServerBrowserOperationError).detail).toMatchObject({ + pendingStages: expect.arrayContaining(["accessibility tree"]), + }); + }); + + it("reports a frame that didn't answer as the frame's own timeout", async () => { + const main = {}; + // A desktop tab refuses frame sessions, so the frame is read in its own world. + const child = { evaluate: never, parentFrame: () => main }; + const captured = await stalledSnapshot({ + page: { + mainFrame: () => main, + frames: () => [main, child], + context: () => ({ + newCDPSession: () => + Promise.reject(new Error("Only this preview tab's page can be attached")), + }), + ariaSnapshot: async () => "- iframe [ref=f1]:\n - text: inside", + locator: () => ({ + elementHandle: async () => ({ contentFrame: async () => child, dispose: async () => {} }), + }), + }, + // Both attempts wait out the frame; neither reaches the capture deadline. + advanceMs: 2_000, + }); + expect(captured).toMatchObject({ + tag: "PreviewAutomationTimeoutError", + detail: { timeoutMs: 1_000, pendingStages: ["frames"] }, + }); + }); }); describe("server browser element refs", () => { diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 036581d9ca99e..09d9b4b833a39 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -229,15 +229,36 @@ const pageChanged = () => "The page changed while capturing its snapshot. Take another snapshot.", ); -const snapshotTimedOut = ( - pendingStages: ReadonlyArray = [], -) => +type SnapshotStage = typeof PreviewAutomationSnapshotStage.Type; + +/** + * Running stages are counted, since a retry can overlap the previous attempt's unanswered + * screenshot. A stage whose own timeout fired has stopped running but is still the cause. + */ +interface SnapshotStages { + readonly running: Map; + readonly timedOut: Set; +} + +const snapshotTimedOut = (stages?: SnapshotStages) => new ServerBrowserOperationError( "PreviewAutomationTimeoutError", `Snapshot capture timed out after ${SNAPSHOT_TIMEOUT_MS}ms. Try the snapshot again.`, - { timeoutMs: SNAPSHOT_TIMEOUT_MS, pendingStages }, + { + timeoutMs: SNAPSHOT_TIMEOUT_MS, + pendingStages: stages ? [...new Set([...stages.timedOut, ...stages.running.keys()])] : [], + }, ); +const frameUnanswered = () => + new ServerBrowserOperationError( + "PreviewAutomationTimeoutError", + `A frame on the page didn't answer within ${FRAME_ANSWER_MS}ms, so its content couldn't be checked for hidden values. Try the snapshot again.`, + { timeoutMs: FRAME_ANSWER_MS, pendingStages: ["frames"] }, + ); + +const isTimeout = (cause: unknown) => cause instanceof Error && cause.name === "TimeoutError"; + const VALUE_TOO_LONG = "A password field holds a value too long to hide, so no snapshot was taken."; /** A frame in another process keeps one CDP session, so none pile up across snapshots. */ @@ -539,20 +560,20 @@ const renderingOf = async (input: SnapshotInput) => { const capture = async ( input: SnapshotInput, deadline: number, - pending: Map, + stages: SnapshotStages, ): Promise => { - const track = async ( - stage: typeof PreviewAutomationSnapshotStage.Type, - operation: () => Promise, - ) => { - // A retry can overlap the previous attempt's unanswered screenshot. - pending.set(stage, (pending.get(stage) ?? 0) + 1); + const { running } = stages; + const track = async (stage: SnapshotStage, operation: () => Promise) => { + running.set(stage, (running.get(stage) ?? 0) + 1); try { return await operation(); + } catch (cause) { + if (isTimeout(cause)) stages.timedOut.add(stage); + throw cause; } finally { - const count = (pending.get(stage) ?? 1) - 1; - if (count === 0) pending.delete(stage); - else pending.set(stage, count); + const count = (running.get(stage) ?? 1) - 1; + if (count === 0) running.delete(stage); + else running.set(stage, count); } }; const { renderScale, viewport, offset } = await track("metrics", () => renderingOf(input)); @@ -604,7 +625,7 @@ const capture = async ( if (!iframe) throw pageChanged(); const frame = await iframe.contentFrame(); void iframe.dispose().catch(constVoid); - if (frame && unread.includes(frame)) throw snapshotTimedOut(); + if (frame && unread.includes(frame)) throw frameUnanswered(); } const [page, data] = await pageAndScreenshot; if (state.generation !== generation) throw pageChanged(); @@ -641,29 +662,27 @@ const snapshotTurns = new WeakMap(); export const snapshot = async (input: SnapshotInput): Promise => { const deadline = Date.now() + SNAPSHOT_TIMEOUT_MS; - const pending = new Map(); + const stages: SnapshotStages = { running: new Map(), timedOut: new Set() }; let timer: ReturnType | undefined; const expired = new Promise((_, reject) => { - timer = setTimeout(() => reject(snapshotTimedOut([...pending.keys()])), SNAPSHOT_TIMEOUT_MS); + timer = setTimeout(() => reject(snapshotTimedOut(stages)), SNAPSHOT_TIMEOUT_MS); }); const turn = (snapshotTurns.get(input.page) ?? 0) + 1; snapshotTurns.set(input.page, turn); // A page that changed mid-capture, like one rotating ads, has usually settled by a second // try. None starts late or after another snapshot began, since it would revoke the refs of // whichever snapshot came next. - const captured = capture(input, deadline, pending).catch((cause: unknown) => + const captured = capture(input, deadline, stages).catch((cause: unknown) => Date.now() < deadline && snapshotTurns.get(input.page) === turn && !(cause instanceof Error && cause.message === VALUE_TOO_LONG) - ? capture(input, deadline, pending) + ? capture(input, deadline, stages) : Promise.reject(cause), ); try { return await Promise.race([captured, expired]); } catch (cause) { - throw cause instanceof Error && cause.name === "TimeoutError" - ? snapshotTimedOut([...pending.keys()]) - : cause; + throw isTimeout(cause) ? snapshotTimedOut(stages) : cause; } finally { clearTimeout(timer); } From 71dd2616b70e3242257bfe7264f1e73f4c6c1cc7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 12:00:55 +0000 Subject: [PATCH 3/3] fix(server): keep snapshot stage diagnostics current across retries --- .../src/preview/ServerBrowserPage.test.ts | 43 +++++++++++++++++++ apps/server/src/preview/ServerBrowserPage.ts | 10 +++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 3a43f876dcfd9..37b9f6f45729d 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -92,6 +92,49 @@ describe("snapshot deadline diagnostics", () => { }); }); + it("omits a timed-out stage after it succeeds on retry", async () => { + let attempts = 0; + const timedOut = Object.assign(new Error("Timeout exceeded"), { name: "TimeoutError" }); + expect( + await stalledSnapshot({ + page: { + ariaSnapshot: async () => { + if (attempts++ === 0) throw timedOut; + return ""; + }, + }, + advanceMs: 10_000, + }), + ).toMatchObject({ detail: { timeoutMs: 10_000, pendingStages: ["screenshot"] } }); + }); + + it.each(["elementHandle", "contentFrame"] as const)( + "identifies stalled iframe %s lookup", + async (stage) => { + const main = {}; + const child = { evaluate: never, parentFrame: () => main }; + const captured = await stalledSnapshot({ + page: { + mainFrame: () => main, + frames: () => [main, child], + context: () => ({ + newCDPSession: () => + Promise.reject(new Error("Only this preview tab's page can be attached")), + }), + ariaSnapshot: async () => "- iframe [ref=f1]:\n - text: inside", + locator: () => ({ + elementHandle: + stage === "elementHandle" ? never : async () => ({ contentFrame: never }), + }), + }, + advanceMs: 10_000, + }); + expect(captured).toMatchObject({ + detail: { timeoutMs: 10_000, pendingStages: expect.arrayContaining(["frames"]) }, + }); + }, + ); + it("reports a frame that didn't answer as the frame's own timeout", async () => { const main = {}; // A desktop tab refuses frame sessions, so the frame is read in its own world. diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 163be91c74af7..54a9d33fcfd19 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -555,7 +555,9 @@ const capture = async ( const track = async (stage: SnapshotStage, operation: () => Promise) => { running.set(stage, (running.get(stage) ?? 0) + 1); try { - return await operation(); + const result = await operation(); + stages.timedOut.delete(stage); + return result; } catch (cause) { if (isTimeout(cause)) stages.timedOut.add(stage); throw cause; @@ -610,9 +612,11 @@ const capture = async ( const unread = readers.flatMap((reader, i) => (after[i] ? [] : reader.frames)); const filled = /^\s*- iframe (?:\[active\] )?\[ref=(\S+?)\].*:$/gm; for (const [, ref] of unread.length ? tree.matchAll(filled) : []) { - const iframe = await input.page.locator(`aria-ref=${ref}`).elementHandle({ timeout: 2_000 }); + const iframe = await track("frames", () => + input.page.locator(`aria-ref=${ref}`).elementHandle({ timeout: 2_000 }), + ); if (!iframe) throw pageChanged(); - const frame = await iframe.contentFrame(); + const frame = await track("frames", () => iframe.contentFrame()); void iframe.dispose().catch(constVoid); if (frame && unread.includes(frame)) throw frameUnanswered(); }