Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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<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 @@ -206,6 +207,7 @@ const readRemoteTimeoutMs = (detail: unknown): number | undefined => {
};

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

const classifyResponseError = (
context: PreviewAutomationRequestErrorContext,
Expand Down Expand Up @@ -255,12 +257,14 @@ const classifyResponseError = (
});
case "PreviewAutomationTimeoutError": {
const remoteTimeoutMs = readRemoteTimeoutMs(error.detail);
const detail = decodeTimeoutDetail(error.detail);
return new PreviewAutomationTimeoutError({
...context,
...remoteDiagnostics,
...(remoteTimeoutMs === undefined
? {}
: { timeoutMs: Math.min(context.timeoutMs, remoteTimeoutMs) }),
...(Option.isSome(detail) ? { pendingStages: detail.value.pendingStages } : {}),
});
}
case "PreviewAutomationControlInterruptedError": {
Expand Down
155 changes: 154 additions & 1 deletion apps/server/src/preview/ServerBrowserPage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,163 @@ 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", () => {
const never = () => new Promise<never>(() => {});
const stalledSnapshot = async (options: {
readonly stalled?: "metrics" | "screenshot";
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" || 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) => {
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("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.
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", () => {
let browser: Browser;
let context: BrowserContext;
Expand Down
Loading
Loading