mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:40:44 +00:00
feat(browser): add qa web runtime support
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user