feat(browser): add qa web runtime support

This commit is contained in:
Peter Steinberger
2026-04-12 19:40:24 -07:00
parent c848ebc8ce
commit 1a47660518
9 changed files with 820 additions and 16 deletions

View File

@@ -20,22 +20,33 @@ type ChromeMcpSessionFactory = Exclude<
type ChromeMcpSession = Awaited<ReturnType<ChromeMcpSessionFactory>>;
function createFakeSession(): ChromeMcpSession {
const callTool = vi.fn(async ({ name }: ToolCall) => {
let currentUrl =
"https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session";
let createdPageOpen = false;
const readUrlArg = (value: unknown, fallback: string) =>
typeof value === "string" && value.trim() ? value : fallback;
const callTool = vi.fn(async ({ name, arguments: args }: ToolCall) => {
if (name === "list_pages") {
const pageLines = [
"## Pages",
`1: ${currentUrl} [selected]`,
"2: https://github.com/openclaw/openclaw/pull/45318",
];
if (createdPageOpen) {
pageLines.push(`3: ${currentUrl}`);
}
return {
content: [
{
type: "text",
text: [
"## Pages",
"1: https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session [selected]",
"2: https://github.com/openclaw/openclaw/pull/45318",
].join("\n"),
text: pageLines.join("\n"),
},
],
};
}
if (name === "new_page") {
currentUrl = readUrlArg(args?.url, "about:blank");
createdPageOpen = true;
return {
content: [
{
@@ -44,12 +55,16 @@ function createFakeSession(): ChromeMcpSession {
"## Pages",
"1: https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session",
"2: https://github.com/openclaw/openclaw/pull/45318",
"3: https://example.com/ [selected]",
`3: ${currentUrl} [selected]`,
].join("\n"),
},
],
};
}
if (name === "navigate_page") {
currentUrl = readUrlArg(args?.url, currentUrl);
return { content: [{ type: "text", text: "navigated" }] };
}
if (name === "evaluate_script") {
return {
content: [
@@ -130,6 +145,28 @@ describe("chrome MCP page parsing", () => {
});
});
it("opens about:blank directly without an extra navigate", async () => {
const session = createFakeSession();
const factory: ChromeMcpSessionFactory = async () => session;
setChromeMcpSessionFactoryForTest(factory);
const tab = await openChromeMcpTab("chrome-live", "about:blank");
expect(tab).toEqual({
targetId: "3",
title: "",
url: "about:blank",
type: "page",
});
expect(session.client.callTool).toHaveBeenCalledWith({
name: "new_page",
arguments: { url: "about:blank", timeout: 5000 },
});
expect(session.client.callTool).not.toHaveBeenCalledWith(
expect.objectContaining({ name: "navigate_page" }),
);
});
it("parses evaluate_script text responses when structuredContent is missing", async () => {
const factory: ChromeMcpSessionFactory = async () => createFakeSession();
setChromeMcpSessionFactoryForTest(factory);

View File

@@ -42,6 +42,8 @@ const DEFAULT_CHROME_MCP_ARGS = [
"--experimentalStructuredContent",
"--experimental-page-id-routing",
];
const CHROME_MCP_NEW_PAGE_TIMEOUT_MS = 5_000;
const CHROME_MCP_NAVIGATE_TIMEOUT_MS = 20_000;
const sessions = new Map<string, ChromeMcpSession>();
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
@@ -401,16 +403,33 @@ export async function openChromeMcpTab(
url: string,
userDataDir?: string,
): Promise<BrowserTab> {
const result = await callTool(profileName, userDataDir, "new_page", { url });
const targetUrl = url.trim() || "about:blank";
const result = await callTool(profileName, userDataDir, "new_page", {
url: "about:blank",
timeout: CHROME_MCP_NEW_PAGE_TIMEOUT_MS,
});
const pages = extractStructuredPages(result);
const chosen = pages.find((page) => page.selected) ?? pages.at(-1);
if (!chosen) {
throw new Error("Chrome MCP did not return the created page.");
}
const targetId = String(chosen.id);
const finalUrl =
targetUrl === "about:blank"
? (chosen.url ?? targetUrl)
: (
await navigateChromeMcpPage({
profileName,
userDataDir,
targetId,
url: targetUrl,
timeoutMs: CHROME_MCP_NAVIGATE_TIMEOUT_MS,
})
).url;
return {
targetId: String(chosen.id),
targetId,
title: "",
url: chosen.url ?? url,
url: finalUrl,
type: "page",
};
}

View File

@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
createBrowserControlContextMock,
createBrowserRouteDispatcherMock,
loadConfigMock,
startBrowserControlServiceFromConfigMock,
withTimeoutMock,
} = vi.hoisted(() => ({
createBrowserControlContextMock: vi.fn(() => ({ ok: true })),
createBrowserRouteDispatcherMock: vi.fn(),
loadConfigMock: vi.fn(),
startBrowserControlServiceFromConfigMock: vi.fn(),
withTimeoutMock: vi.fn(),
}));
vi.mock("../core-api.js", async () => {
const actual = await vi.importActual<typeof import("../core-api.js")>("../core-api.js");
return {
...actual,
createBrowserControlContext: createBrowserControlContextMock,
createBrowserRouteDispatcher: createBrowserRouteDispatcherMock,
loadConfig: loadConfigMock,
startBrowserControlServiceFromConfig: startBrowserControlServiceFromConfigMock,
withTimeout: withTimeoutMock,
};
});
import { browserHandlers } from "./browser-request.js";
describe("browser.request local timeout", () => {
beforeEach(() => {
loadConfigMock.mockReturnValue({
gateway: { nodes: { browser: { mode: "off" } } },
});
startBrowserControlServiceFromConfigMock.mockResolvedValue(true);
createBrowserRouteDispatcherMock.mockReturnValue({
dispatch: vi.fn(async () => ({ status: 200, body: { ok: true } })),
});
withTimeoutMock.mockImplementation(async () => {
throw new Error("browser request timed out");
});
});
it("applies timeoutMs to local browser dispatches", async () => {
const respond = vi.fn();
await browserHandlers["browser.request"]({
params: {
method: "POST",
path: "/tabs/open",
body: { url: "https://example.com" },
timeoutMs: 4321,
},
respond: respond as never,
context: {
nodeRegistry: { listConnected: () => [] },
} as never,
client: null,
req: { type: "req", id: "req-1", method: "browser.request" },
isWebchatConnect: () => false,
});
expect(withTimeoutMock).toHaveBeenCalledWith(expect.any(Function), 4321, "browser request");
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({
message: "Error: browser request timed out",
}),
);
});
});

View File

@@ -18,6 +18,7 @@ import {
respondUnavailableOnNodeInvokeError,
safeParseJson,
startBrowserControlServiceFromConfig,
withTimeout,
type GatewayRequestHandlers,
type NodeSession,
} from "../core-api.js";
@@ -246,12 +247,31 @@ export async function handleBrowserGatewayRequest({
return;
}
const result = await dispatcher.dispatch({
method: methodRaw,
path,
query,
body,
});
let result;
try {
result = timeoutMs
? await withTimeout(
(signal) =>
dispatcher.dispatch({
method: methodRaw,
path,
query,
body,
signal,
}),
timeoutMs,
"browser request",
)
: await dispatcher.dispatch({
method: methodRaw,
path,
query,
body,
});
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
return;
}
if (result.status >= 400) {
const message =