Skip to content
37 changes: 37 additions & 0 deletions apps/server/src/mcp/PreviewAutomationBroker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,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<void>();
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<void>({ 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;
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/mcp/PreviewAutomationBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
PreviewAutomationTabNotFoundError,
PreviewAutomationTargetNotEditableError,
PreviewAutomationTimeoutError,
PreviewAutomationTimeoutDetail,
PreviewAutomationUnsupportedClientError,
PreviewTabId,
type PreviewAutomationError,
Expand Down Expand Up @@ -214,6 +215,7 @@ const readRemoteReason = (detail: unknown): string | undefined =>
: undefined;

const MAX_REASON_CHARS = 500;
const decodeTimeoutDetail = Schema.decodeUnknownOption(PreviewAutomationTimeoutDetail);

const classifyResponseError = (
context: PreviewAutomationRequestErrorContext,
Expand Down Expand Up @@ -263,6 +265,7 @@ const classifyResponseError = (
});
case "PreviewAutomationTimeoutError": {
const remoteTimeoutMs = readRemoteTimeoutMs(error.detail);
const detail = decodeTimeoutDetail(error.detail);
const remoteReason = readRemoteReason(error.detail);
return new PreviewAutomationTimeoutError({
...context,
Expand All @@ -273,6 +276,7 @@ const classifyResponseError = (
...(remoteTimeoutMs === undefined
? {}
: { timeoutMs: Math.min(context.timeoutMs, remoteTimeoutMs) }),
...(Option.isSome(detail) ? { pendingStages: detail.value.pendingStages } : {}),
});
}
case "PreviewAutomationControlInterruptedError": {
Expand Down
143 changes: 143 additions & 0 deletions apps/server/src/preview/ServerBrowserPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,149 @@ import { SessionControl } from "./SessionControl.ts";

const decodeEvaluateInput = Schema.decodeUnknownSync(PreviewAutomationEvaluateInput);

describe("snapshot deadline diagnostics", () => {
const never = () => new Promise<never>(() => {});
const stalledSnapshot = async (options: {
readonly stalled?: "metrics";
readonly page?: Partial<Record<keyof Page, unknown>>;
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") return never();
if (method === "Page.captureScreenshot") return { data: "" };
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("identifies a stalled metrics read", async () => {
expect(await stalledSnapshot({ stalled: "metrics", advanceMs: 10_000 })).toMatchObject({
tag: "PreviewAutomationTimeoutError",
detail: { timeoutMs: 10_000, pendingStages: ["metrics"] },
});
});

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("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: {
evaluate: never,
ariaSnapshot: async () => {
if (attempts++ === 0) throw timedOut;
return "";
},
},
advanceMs: 10_000,
}),
).toMatchObject({ detail: { timeoutMs: 10_000, pendingStages: ["page state"] } });
});

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.
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 capture deadlines", () => {
it("releases control after an unanswered capture and ignores its late reply", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
Expand Down
Loading
Loading