diff --git a/CHANGELOG.md b/CHANGELOG.md index b1f6333a90d..552634c2df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3259,6 +3259,7 @@ Docs: https://docs.openclaw.ai - CLI/plugins: refresh persisted plugin registry policy in place for `plugins enable` and `plugins disable`, so routine toggles no longer rebuild and hash every plugin source when the target is already indexed. Thanks @vincentkoc. - Windows/install: run npm from a writable installer temp directory and pin the Bedrock runtime dependency below a Windows ARM Node 24 npm resolver failure, so global OpenClaw installs no longer fail before onboarding. Thanks @mariozechner. - CLI/plugins: scope install and enable slot selection to the selected plugin manifest/runtime fallback, so plugin installs no longer load every plugin runtime or broad status snapshot just to update memory/context slots. Thanks @vincentkoc. +- Browser/snapshot: propagate the configured snapshot timeout through the agent tool, Chrome MCP, and Playwright snapshot paths so snapshot actions honor the requested deadline instead of hanging. Fixes #72934. Thanks @masatohoshino. - Plugins/TTS: keep bundled speech-provider discovery available on cold package Gateway paths and add bundled plugin matrix runtime probes for health, readiness, RPC, TTS discovery, and post-ready runtime-deps watchdog coverage. Refs #75283. Thanks @vincentkoc. - Google Meet/Twilio: show delegated voice call ID, DTMF, and intro-greeting state in `googlemeet doctor`, and avoid claiming DTMF was sent when no Meet PIN sequence was configured. Refs #72478. Thanks @DougButdorf. - Plugins/tools: prefer built bundled plugin code during tool discovery and skip channel runtime hydration while preserving companion provider registrations, reducing per-run plugin-tool prep cost without dropping executable plugin tools. Fixes #75290. Thanks @thanos-openclaw. diff --git a/extensions/browser/src/browser-tool.actions.ts b/extensions/browser/src/browser-tool.actions.ts index f7ac173672d..f18badbfef7 100644 --- a/extensions/browser/src/browser-tool.actions.ts +++ b/extensions/browser/src/browser-tool.actions.ts @@ -16,7 +16,10 @@ import { resolveRuntimeImageSanitization, wrapExternalContent, } from "./browser-tool.runtime.js"; -import { DEFAULT_BROWSER_ACTION_TIMEOUT_MS } from "./browser/constants.js"; +import { + DEFAULT_BROWSER_ACTION_TIMEOUT_MS, + DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS, +} from "./browser/constants.js"; const browserToolActionDeps = { browserAct, @@ -365,6 +368,8 @@ export async function executeSnapshotAction(params: { : hasMaxChars ? maxChars : undefined; + const snapshotTimeoutMs = + normalizePositiveTimeoutMs(input.timeoutMs) ?? DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS; const snapshotQuery = { ...(format ? { format } : {}), targetId, @@ -379,6 +384,7 @@ export async function executeSnapshotAction(params: { labels, urls, mode, + timeoutMs: snapshotTimeoutMs, }; let refsFallback: "role" | undefined; const readSnapshot = async (query: typeof snapshotQuery) => @@ -388,6 +394,7 @@ export async function executeSnapshotAction(params: { path: "/snapshot", profile, query, + timeoutMs: snapshotTimeoutMs, })) as Awaited>) : await browserToolActionDeps.browserSnapshot(baseUrl, { ...query, diff --git a/extensions/browser/src/browser-tool.test.ts b/extensions/browser/src/browser-tool.test.ts index 2547167dbfb..949579907d9 100644 --- a/extensions/browser/src/browser-tool.test.ts +++ b/extensions/browser/src/browser-tool.test.ts @@ -554,6 +554,86 @@ describe("browser tool snapshot maxChars", () => { expect(opts.refs).toBe("aria"); }); + it("propagates input.timeoutMs into the direct browser snapshot call", async () => { + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "snapshot", + target: "host", + snapshotFormat: "ai", + timeoutMs: 9000, + }); + + expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + format: "ai", + timeoutMs: 9000, + }), + ); + }); + + it("falls back to the default snapshot timeout in the direct browser snapshot call", async () => { + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "snapshot", + target: "host", + snapshotFormat: "ai", + }); + + expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + format: "ai", + // DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS = 20_000. + timeoutMs: 20_000, + }), + ); + }); + + it("propagates input.timeoutMs into the proxied browser snapshot request", async () => { + mockSingleBrowserProxyNode(); + setResolvedBrowserProfiles({ + user: { driver: "existing-session", attachOnly: true, color: "#00AA00" }, + }); + gatewayMocks.callGatewayTool.mockResolvedValue({ + ok: true, + payload: { + result: { + ok: true, + format: "ai", + targetId: "t1", + url: "https://x", + snapshot: "ok", + }, + files: [], + }, + }); + const tool = createBrowserTool(); + await tool.execute?.("call-1", { + action: "snapshot", + target: "node", + profile: "user", + snapshotFormat: "ai", + timeoutMs: 7777, + }); + + expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith( + "node.invoke", + // proxy adds a 5_000 ms slack on top of the per-request timeout. + expect.objectContaining({ timeoutMs: 7777 + 5_000 }), + expect.objectContaining({ + command: "browser.proxy", + params: expect.objectContaining({ + method: "GET", + path: "/snapshot", + profile: "user", + query: expect.objectContaining({ timeoutMs: 7777 }), + timeoutMs: 7777, + }), + }), + ); + }); + it("uses config snapshot defaults when mode is not provided", async () => { configMocks.loadConfig.mockReturnValue({ browser: { snapshotDefaults: { mode: "efficient" } }, diff --git a/extensions/browser/src/browser/chrome-mcp.test.ts b/extensions/browser/src/browser/chrome-mcp.test.ts index cf7c13ec2fa..81cb972ef42 100644 --- a/extensions/browser/src/browser/chrome-mcp.test.ts +++ b/extensions/browser/src/browser/chrome-mcp.test.ts @@ -13,6 +13,7 @@ import { resetChromeMcpSessionsForTest, setChromeMcpSessionFactoryForTest, takeChromeMcpScreenshot, + takeChromeMcpSnapshot, } from "./chrome-mcp.js"; type ToolCall = { @@ -856,6 +857,27 @@ describe("chrome MCP page parsing", () => { expect(tabs).toHaveLength(2); }); + it("forwards an explicit timeoutMs to take_snapshot via the callTool race", async () => { + vi.useFakeTimers(); + const session = createFakeSession(); + session.client.callTool = vi.fn( + async () => new Promise(() => {}), + ) as typeof session.client.callTool; + setChromeMcpSessionFactoryForTest(async () => session); + + const snapshotPromise = takeChromeMcpSnapshot({ + profileName: "chrome-live", + targetId: "1", + timeoutMs: 75, + }); + void snapshotPromise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(75); + + await expect(snapshotPromise).rejects.toThrow(/Chrome MCP "take_snapshot".*timed out/); + vi.useRealTimers(); + }); + it("honors timeoutMs for ephemeral availability probes", async () => { vi.useFakeTimers(); const closeMock = vi.fn().mockResolvedValue(undefined); diff --git a/extensions/browser/src/browser/chrome-mcp.ts b/extensions/browser/src/browser/chrome-mcp.ts index a084f2fb6a1..0614c9ec830 100644 --- a/extensions/browser/src/browser/chrome-mcp.ts +++ b/extensions/browser/src/browser/chrome-mcp.ts @@ -1019,6 +1019,7 @@ export async function takeChromeMcpSnapshot(params: { profile?: ChromeMcpProfileOptions; userDataDir?: string; targetId: string; + timeoutMs?: number; }): Promise { const result = await callTool( params.profileName, @@ -1027,6 +1028,7 @@ export async function takeChromeMcpSnapshot(params: { { pageId: parsePageId(params.targetId), }, + { timeoutMs: params.timeoutMs }, ); return extractSnapshot(result); } diff --git a/extensions/browser/src/browser/client.test.ts b/extensions/browser/src/browser/client.test.ts index 475fd099ac9..52ca64d9f94 100644 --- a/extensions/browser/src/browser/client.test.ts +++ b/extensions/browser/src/browser/client.test.ts @@ -112,6 +112,33 @@ describe("browser client", () => { expect(parsed.searchParams.get("refs")).toBe("aria"); }); + it("forwards an explicit snapshot timeoutMs into the query string", async () => { + const calls: string[] = []; + stubSnapshotFetch(calls); + + await browserSnapshot("http://127.0.0.1:18791", { + format: "ai", + timeoutMs: 4321, + }); + + const snapshotCall = calls.find((url) => url.includes("/snapshot?")); + expect(snapshotCall).toBeTruthy(); + const parsed = new URL(snapshotCall as string); + expect(parsed.searchParams.get("timeoutMs")).toBe("4321"); + }); + + it("falls back to the default snapshot timeout when none is supplied", async () => { + const calls: string[] = []; + stubSnapshotFetch(calls); + + await browserSnapshot("http://127.0.0.1:18791", { format: "ai" }); + + const snapshotCall = calls.find((url) => url.includes("/snapshot?")); + expect(snapshotCall).toBeTruthy(); + const parsed = new URL(snapshotCall as string); + expect(parsed.searchParams.get("timeoutMs")).toBe("20000"); + }); + it("omits format when the caller wants server-side snapshot capability defaults", async () => { const calls: string[] = []; stubSnapshotFetch(calls); diff --git a/extensions/browser/src/browser/client.ts b/extensions/browser/src/browser/client.ts index 80d825d258b..38b4cbc6810 100644 --- a/extensions/browser/src/browser/client.ts +++ b/extensions/browser/src/browser/client.ts @@ -6,6 +6,7 @@ import type { BrowserTransport, SnapshotAriaNode, } from "./client.types.js"; +import { DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS } from "./constants.js"; import type { BrowserDoctorReport } from "./doctor.js"; export type { BrowserStatus, BrowserTab, BrowserTransport } from "./client.types.js"; @@ -320,6 +321,7 @@ export async function browserSnapshot( urls?: boolean; mode?: "efficient"; profile?: string; + timeoutMs?: number; }, ): Promise { const q = new URLSearchParams(); @@ -365,8 +367,13 @@ export async function browserSnapshot( if (opts.profile) { q.set("profile", opts.profile); } + const resolvedTimeoutMs = + typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 + ? Math.floor(opts.timeoutMs) + : DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS; + q.set("timeoutMs", String(resolvedTimeoutMs)); return await fetchBrowserJson(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), { - timeoutMs: 20000, + timeoutMs: resolvedTimeoutMs, }); } diff --git a/extensions/browser/src/browser/constants.ts b/extensions/browser/src/browser/constants.ts index 83093c96c58..01a27c10ade 100644 --- a/extensions/browser/src/browser/constants.ts +++ b/extensions/browser/src/browser/constants.ts @@ -7,6 +7,7 @@ export const DEFAULT_BROWSER_ACTION_TIMEOUT_MS = 60_000; export const DEFAULT_BROWSER_LOCAL_LAUNCH_TIMEOUT_MS = 15_000; export const DEFAULT_BROWSER_LOCAL_CDP_READY_TIMEOUT_MS = 8_000; export const DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS = 20_000; +export const DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS = 20_000; export const DEFAULT_BROWSER_TAB_CLEANUP_IDLE_MINUTES = 120; export const DEFAULT_BROWSER_TAB_CLEANUP_MAX_TABS_PER_SESSION = 8; export const DEFAULT_BROWSER_TAB_CLEANUP_SWEEP_MINUTES = 5; diff --git a/extensions/browser/src/browser/pw-tools-core.snapshot.test.ts b/extensions/browser/src/browser/pw-tools-core.snapshot.test.ts index 3b07f06e254..ffbb41d7464 100644 --- a/extensions/browser/src/browser/pw-tools-core.snapshot.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.snapshot.test.ts @@ -92,6 +92,45 @@ describe("pw-tools-core aria snapshot storage", () => { }); }); + it("races snapshotAriaViaPlaywright against an explicit timeoutMs", async () => { + vi.useFakeTimers(); + try { + const page = { id: "page-1" }; + getPageForTargetId.mockResolvedValue(page); + withPageScopedCdpClient.mockImplementation(() => new Promise(() => {})); + + const mod = await import("./pw-tools-core.snapshot.js"); + const promise = mod.snapshotAriaViaPlaywright({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + timeoutMs: 750, + }); + void promise.catch(() => {}); + + await vi.advanceTimersByTimeAsync(750); + + await expect(promise).rejects.toThrow(/Aria snapshot via Playwright timed out/); + } finally { + vi.useRealTimers(); + } + }); + + it("forwards an explicit timeoutMs into the role-aria Playwright ariaSnapshot call", async () => { + const ariaSnapshotMock = vi.fn().mockResolvedValue(""); + const page = { ariaSnapshot: ariaSnapshotMock }; + getPageForTargetId.mockResolvedValue(page); + + const mod = await import("./pw-tools-core.snapshot.js"); + await mod.snapshotRoleViaPlaywright({ + cdpUrl: "http://127.0.0.1:9222", + targetId: "tab-1", + refsMode: "aria", + timeoutMs: 8888, + }); + + expect(ariaSnapshotMock).toHaveBeenCalledWith({ mode: "ai", timeout: 8888 }); + }); + it("stores role fallback metadata when backend markers are unavailable", async () => { const page = { id: "page-1" }; const mod = await import("./pw-tools-core.snapshot.js"); diff --git a/extensions/browser/src/browser/pw-tools-core.snapshot.ts b/extensions/browser/src/browser/pw-tools-core.snapshot.ts index 3d721bd3d72..357c74b3e36 100644 --- a/extensions/browser/src/browser/pw-tools-core.snapshot.ts +++ b/extensions/browser/src/browser/pw-tools-core.snapshot.ts @@ -140,6 +140,7 @@ export async function snapshotAriaViaPlaywright(opts: { cdpUrl: string; targetId?: string; limit?: number; + timeoutMs?: number; ssrfPolicy?: SsrFPolicy; }): Promise<{ nodes: AriaSnapshotNode[] }> { const limit = Math.max(1, Math.min(2000, Math.floor(opts.limit ?? 500))); @@ -157,7 +158,11 @@ export async function snapshotAriaViaPlaywright(opts: { targetId: opts.targetId, }); } - const res = (await withPageScopedCdpClient({ + const ariaTimeoutMs = + typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 + ? Math.max(500, Math.min(60_000, Math.floor(opts.timeoutMs))) + : undefined; + const collectAxTree = withPageScopedCdpClient({ cdpUrl: opts.cdpUrl, page, targetId: opts.targetId, @@ -167,7 +172,23 @@ export async function snapshotAriaViaPlaywright(opts: { nodes?: RawAXNode[]; }; }, - })) as { + }); + const res = (await (ariaTimeoutMs === undefined + ? collectAxTree + : (() => { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`Aria snapshot via Playwright timed out after ${ariaTimeoutMs}ms.`)); + }, ariaTimeoutMs); + timer.unref?.(); + }); + return Promise.race([collectAxTree, timeout]).finally(() => { + if (timer) { + clearTimeout(timer); + } + }); + })())) as { nodes?: RawAXNode[]; }; const nodes = Array.isArray(res?.nodes) ? res.nodes : []; @@ -241,6 +262,7 @@ export async function snapshotRoleViaPlaywright(opts: { refsMode?: "role" | "aria"; options?: RoleSnapshotOptions; urls?: boolean; + timeoutMs?: number; ssrfPolicy?: SsrFPolicy; }): Promise<{ snapshot: string; @@ -262,13 +284,15 @@ export async function snapshotRoleViaPlaywright(opts: { }); } + const ariaSnapshotTimeout = Math.max(500, Math.min(60_000, Math.floor(opts.timeoutMs ?? 5000))); + if (opts.refsMode === "aria") { if (normalizeOptionalString(opts.selector) || normalizeOptionalString(opts.frameSelector)) { throw new Error("refs=aria does not support selector/frame snapshots yet."); } const snapshot = await page.ariaSnapshot({ mode: "ai", - timeout: 5000, + timeout: ariaSnapshotTimeout, }); const built = buildRoleSnapshotFromAiSnapshot(snapshot, opts.options); const snapshotWithUrls = opts.urls @@ -298,7 +322,7 @@ export async function snapshotRoleViaPlaywright(opts: { ? page.locator(selector) : page.locator(":root"); - const ariaSnapshot = await locator.ariaSnapshot(); + const ariaSnapshot = await locator.ariaSnapshot({ timeout: ariaSnapshotTimeout }); const built = buildRoleSnapshotFromAriaSnapshot(ariaSnapshot ?? "", opts.options); const snapshotWithUrls = opts.urls ? appendSnapshotUrls(built.snapshot, await collectSnapshotUrls(page)) diff --git a/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts b/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts index b5d5cc0d168..7105589b10d 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.plan.test.ts @@ -47,4 +47,31 @@ describe("resolveSnapshotPlan", () => { expect(plan.urls).toBe(true); expect(plan.wantsRoleSnapshot).toBe(true); }); + + it("parses timeoutMs from the snapshot query string", () => { + const plan = resolveSnapshotPlan({ + profile: profile("openclaw"), + query: { timeoutMs: "12345" }, + hasPlaywright: true, + }); + + expect(plan.timeoutMs).toBe(12345); + }); + + it("ignores non-positive timeoutMs values", () => { + expect( + resolveSnapshotPlan({ + profile: profile("openclaw"), + query: { timeoutMs: "0" }, + hasPlaywright: true, + }).timeoutMs, + ).toBeUndefined(); + expect( + resolveSnapshotPlan({ + profile: profile("openclaw"), + query: { timeoutMs: "not-a-number" }, + hasPlaywright: true, + }).timeoutMs, + ).toBeUndefined(); + }); }); diff --git a/extensions/browser/src/browser/routes/agent.snapshot.plan.ts b/extensions/browser/src/browser/routes/agent.snapshot.plan.ts index 90f8d3dc958..5d59d250299 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.plan.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.plan.ts @@ -32,6 +32,7 @@ type BrowserSnapshotPlan = { refsMode?: "aria" | "role"; selectorValue?: string; frameSelectorValue?: string; + timeoutMs?: number; wantsRoleSnapshot: boolean; }; @@ -79,6 +80,11 @@ export function resolveSnapshotPlan(params: { depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined); const selectorValue = normalizeOptionalString(toStringOrEmpty(params.query.selector)); const frameSelectorValue = normalizeOptionalString(toStringOrEmpty(params.query.frame)); + const timeoutMsRaw = toNumber(params.query.timeoutMs); + const timeoutMs = + timeoutMsRaw !== undefined && Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 + ? Math.max(1, Math.floor(timeoutMsRaw)) + : undefined; return { format, @@ -93,6 +99,7 @@ export function resolveSnapshotPlan(params: { refsMode, selectorValue, frameSelectorValue, + timeoutMs, wantsRoleSnapshot: labels === true || urls === true || diff --git a/extensions/browser/src/browser/routes/agent.snapshot.timeout.test.ts b/extensions/browser/src/browser/routes/agent.snapshot.timeout.test.ts new file mode 100644 index 00000000000..940d97b4718 --- /dev/null +++ b/extensions/browser/src/browser/routes/agent.snapshot.timeout.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js"; + +const cdpMocks = vi.hoisted(() => ({ + captureScreenshot: vi.fn(), + snapshotAria: vi.fn(async () => ({ nodes: [] })), + snapshotRoleViaCdp: vi.fn(async () => ({ + snapshot: "button Continue", + refs: {}, + stats: { lines: 1, chars: 15, refs: 0, interactive: 0 }, + })), +})); + +const profileContext = vi.hoisted(() => ({ + profile: { + name: "openclaw", + driver: "openclaw" as const, + cdpPort: 18_800, + cdpUrl: "http://127.0.0.1:18800", + cdpHost: "127.0.0.1", + cdpIsLoopback: true, + color: "#FF4500", + headless: false, + attachOnly: false, + }, + ensureTabAvailable: vi.fn(async () => ({ + targetId: "tab-1", + url: "https://example.com", + wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1", + })), +})); + +vi.mock("../cdp.js", () => ({ + captureScreenshot: cdpMocks.captureScreenshot, + snapshotAria: cdpMocks.snapshotAria, + snapshotRoleViaCdp: cdpMocks.snapshotRoleViaCdp, +})); + +vi.mock("../chrome-mcp.js", () => ({ + evaluateChromeMcpScript: vi.fn(), + navigateChromeMcpPage: vi.fn(), + takeChromeMcpScreenshot: vi.fn(), + takeChromeMcpSnapshot: vi.fn(), +})); + +vi.mock("../navigation-guard.js", () => ({ + assertBrowserNavigationAllowed: vi.fn(async () => {}), + assertBrowserNavigationResultAllowed: vi.fn(async () => {}), + withBrowserNavigationPolicy: vi.fn(() => ({})), +})); + +vi.mock("../screenshot.js", () => ({ + DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128, + DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64, + normalizeBrowserScreenshot: vi.fn(async (buffer: Buffer) => ({ + buffer, + contentType: "image/png", + })), +})); + +vi.mock("../../media/store.js", () => ({ + ensureMediaDir: vi.fn(async () => {}), + saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })), +})); + +vi.mock("./agent.shared.js", () => ({ + getPwAiModule: vi.fn(async () => null), + handleRouteError: vi.fn(), + readBody: vi.fn((req: { body?: unknown }) => req.body ?? {}), + requirePwAi: vi.fn(async () => null), + resolveProfileContext: vi.fn(() => profileContext), + withPlaywrightRouteContext: vi.fn(), + withRouteTabContext: vi.fn(), +})); + +const { registerBrowserAgentSnapshotRoutes } = await import("./agent.snapshot.js"); + +function getSnapshotHandler() { + const { app, getHandlers } = createBrowserRouteApp(); + registerBrowserAgentSnapshotRoutes(app, { + state: () => ({ resolved: {} }), + } as never); + const handler = getHandlers.get("/snapshot"); + expect(handler).toBeTypeOf("function"); + return handler; +} + +describe("browser agent snapshot timeout routing", () => { + beforeEach(() => { + cdpMocks.captureScreenshot.mockClear(); + cdpMocks.snapshotAria.mockClear(); + cdpMocks.snapshotRoleViaCdp.mockClear(); + profileContext.ensureTabAvailable.mockClear(); + }); + + it("passes timeoutMs to direct CDP aria snapshots", async () => { + const handler = getSnapshotHandler(); + const response = createBrowserRouteResponse(); + + await handler?.({ params: {}, query: { format: "aria", timeoutMs: "4321" } }, response.res); + + expect(response.statusCode).toBe(200); + expect(cdpMocks.snapshotAria).toHaveBeenCalledWith( + expect.objectContaining({ + wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1", + timeoutMs: 4321, + }), + ); + }); + + it("passes timeoutMs to direct CDP role snapshots", async () => { + const handler = getSnapshotHandler(); + const response = createBrowserRouteResponse(); + + await handler?.({ params: {}, query: { format: "ai", timeoutMs: "9876" } }, response.res); + + expect(response.statusCode).toBe(200); + expect(cdpMocks.snapshotRoleViaCdp).toHaveBeenCalledWith( + expect.objectContaining({ + wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1", + timeoutMs: 9876, + }), + ); + }); +}); diff --git a/extensions/browser/src/browser/routes/agent.snapshot.ts b/extensions/browser/src/browser/routes/agent.snapshot.ts index 97c54277bed..34229358a48 100644 --- a/extensions/browser/src/browser/routes/agent.snapshot.ts +++ b/extensions/browser/src/browser/routes/agent.snapshot.ts @@ -577,6 +577,7 @@ export function registerBrowserAgentSnapshotRoutes( profileName: profileCtx.profile.name, profile: profileCtx.profile, targetId: tab.targetId, + timeoutMs: plan.timeoutMs, }); if (plan.format === "aria") { return res.json({ @@ -623,6 +624,7 @@ export function registerBrowserAgentSnapshotRoutes( profile: profileCtx.profile, targetId: tab.targetId, format: "png", + timeoutMs: plan.timeoutMs, }); const normalized = await normalizeBrowserScreenshot(labeled, { maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, @@ -683,6 +685,7 @@ export function registerBrowserAgentSnapshotRoutes( refsMode: plan.refsMode, ssrfPolicy: ctx.state().resolved.ssrfPolicy, urls: plan.urls, + timeoutMs: plan.timeoutMs, options: { interactive: plan.interactive ?? undefined, compact: plan.compact ?? undefined, @@ -700,6 +703,7 @@ export function registerBrowserAgentSnapshotRoutes( return await snapshotRoleViaCdp({ wsUrl: tab.wsUrl, urls: plan.urls, + timeoutMs: plan.timeoutMs, options: { interactive: plan.interactive ?? undefined, compact: plan.compact ?? undefined, @@ -725,6 +729,7 @@ export function registerBrowserAgentSnapshotRoutes( targetId: tab.targetId, ssrfPolicy: ctx.state().resolved.ssrfPolicy, urls: plan.urls, + timeoutMs: plan.timeoutMs, ...(typeof plan.resolvedMaxChars === "number" ? { maxChars: plan.resolvedMaxChars } : {}), @@ -743,6 +748,7 @@ export function registerBrowserAgentSnapshotRoutes( targetId: tab.targetId, refs: "refs" in snap ? snap.refs : {}, type: "png", + timeoutMs: plan.timeoutMs, }); const normalized = await normalizeBrowserScreenshot(labeled.buffer, { maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE, @@ -797,11 +803,12 @@ export function registerBrowserAgentSnapshotRoutes( cdpUrl: profileCtx.profile.cdpUrl, targetId: tab.targetId, limit: plan.limit, + timeoutMs: plan.timeoutMs, ssrfPolicy: ctx.state().resolved.ssrfPolicy, }); }); })() - : snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit }); + : snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit, timeoutMs: plan.timeoutMs }); const resolved = await Promise.resolve(snap); if (!resolved) { diff --git a/scripts/generate-npm-shrinkwrap.mjs b/scripts/generate-npm-shrinkwrap.mjs index 906ff4f7840..d9068ad16ab 100644 --- a/scripts/generate-npm-shrinkwrap.mjs +++ b/scripts/generate-npm-shrinkwrap.mjs @@ -242,6 +242,7 @@ function disableShrinkwrappedOverrideConflictSources(lockfile, overrideRules) { if (!packages || typeof packages !== "object") { return []; } + /** @type {Set} */ const disabled = new Set(); for (const violation of collectOverrideViolations(lockfile, overrideRules)) { const ancestors = violation.packagePath.slice(0, -1).toReversed();