openclaw/src/browser/chrome-mcp.test.ts
Radek Sienkiewicz 7deb543624
Browser: support non-Chrome existing-session profiles via userDataDir (#48170)
Merged via squash.

Prepared head SHA: e490035a24a3a7f0c17f681250b7ffe2b0dcd3d3
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-16 14:21:22 +01:00

311 lines
9.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildChromeMcpArgs,
evaluateChromeMcpScript,
listChromeMcpTabs,
openChromeMcpTab,
resetChromeMcpSessionsForTest,
setChromeMcpSessionFactoryForTest,
} from "./chrome-mcp.js";
type ToolCall = {
name: string;
arguments?: Record<string, unknown>;
};
type ChromeMcpSessionFactory = Exclude<
Parameters<typeof setChromeMcpSessionFactoryForTest>[0],
null
>;
type ChromeMcpSession = Awaited<ReturnType<ChromeMcpSessionFactory>>;
function createFakeSession(): ChromeMcpSession {
const callTool = vi.fn(async ({ name }: ToolCall) => {
if (name === "list_pages") {
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"),
},
],
};
}
if (name === "new_page") {
return {
content: [
{
type: "text",
text: [
"## 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]",
].join("\n"),
},
],
};
}
if (name === "evaluate_script") {
return {
content: [
{
type: "text",
text: "```json\n123\n```",
},
],
};
}
throw new Error(`unexpected tool ${name}`);
});
return {
client: {
callTool,
listTools: vi.fn().mockResolvedValue({ tools: [{ name: "list_pages" }] }),
close: vi.fn().mockResolvedValue(undefined),
connect: vi.fn().mockResolvedValue(undefined),
},
transport: {
pid: 123,
},
ready: Promise.resolve(),
} as unknown as ChromeMcpSession;
}
describe("chrome MCP page parsing", () => {
beforeEach(async () => {
await resetChromeMcpSessionsForTest();
});
it("parses list_pages text responses when structuredContent is missing", async () => {
const factory: ChromeMcpSessionFactory = async () => createFakeSession();
setChromeMcpSessionFactoryForTest(factory);
const tabs = await listChromeMcpTabs("chrome-live");
expect(tabs).toEqual([
{
targetId: "1",
title: "",
url: "https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session",
type: "page",
},
{
targetId: "2",
title: "",
url: "https://github.com/openclaw/openclaw/pull/45318",
type: "page",
},
]);
});
it("adds --userDataDir when an explicit Chromium profile path is configured", () => {
expect(buildChromeMcpArgs("/tmp/brave-profile")).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--userDataDir",
"/tmp/brave-profile",
]);
});
it("parses new_page text responses and returns the created tab", async () => {
const factory: ChromeMcpSessionFactory = async () => createFakeSession();
setChromeMcpSessionFactoryForTest(factory);
const tab = await openChromeMcpTab("chrome-live", "https://example.com/");
expect(tab).toEqual({
targetId: "3",
title: "",
url: "https://example.com/",
type: "page",
});
});
it("parses evaluate_script text responses when structuredContent is missing", async () => {
const factory: ChromeMcpSessionFactory = async () => createFakeSession();
setChromeMcpSessionFactoryForTest(factory);
const result = await evaluateChromeMcpScript({
profileName: "chrome-live",
targetId: "1",
fn: "() => 123",
});
expect(result).toBe(123);
});
it("surfaces MCP tool errors instead of JSON parse noise", async () => {
const factory: ChromeMcpSessionFactory = async () => {
const session = createFakeSession();
const callTool = vi.fn(async ({ name }: ToolCall) => {
if (name === "evaluate_script") {
return {
content: [
{
type: "text",
text: "Cannot read properties of null (reading 'value')",
},
],
isError: true,
};
}
throw new Error(`unexpected tool ${name}`);
});
session.client.callTool = callTool as typeof session.client.callTool;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
await expect(
evaluateChromeMcpScript({
profileName: "chrome-live",
targetId: "1",
fn: "() => document.getElementById('missing').value",
}),
).rejects.toThrow(/Cannot read properties of null/);
});
it("reuses a single pending session for concurrent requests", async () => {
let factoryCalls = 0;
let releaseFactory!: () => void;
const factoryGate = new Promise<void>((resolve) => {
releaseFactory = resolve;
});
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
await factoryGate;
return createFakeSession();
};
setChromeMcpSessionFactoryForTest(factory);
const tabsPromise = listChromeMcpTabs("chrome-live");
const evalPromise = evaluateChromeMcpScript({
profileName: "chrome-live",
targetId: "1",
fn: "() => 123",
});
releaseFactory();
const [tabs, result] = await Promise.all([tabsPromise, evalPromise]);
expect(factoryCalls).toBe(1);
expect(tabs).toHaveLength(2);
expect(result).toBe(123);
});
it("preserves session after tool-level errors (isError)", async () => {
let factoryCalls = 0;
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
const callTool = vi.fn(async ({ name }: ToolCall) => {
if (name === "evaluate_script") {
return {
content: [{ type: "text", text: "element not found" }],
isError: true,
};
}
if (name === "list_pages") {
return {
content: [{ type: "text", text: "## Pages\n1: https://example.com [selected]" }],
};
}
throw new Error(`unexpected tool ${name}`);
});
session.client.callTool = callTool as typeof session.client.callTool;
return session;
};
setChromeMcpSessionFactoryForTest(factory);
// First call: tool error (isError: true) — should NOT destroy session
await expect(
evaluateChromeMcpScript({ profileName: "chrome-live", targetId: "1", fn: "() => null" }),
).rejects.toThrow(/element not found/);
// Second call: should reuse the same session (factory called only once)
const tabs = await listChromeMcpTabs("chrome-live");
expect(factoryCalls).toBe(1);
expect(tabs).toHaveLength(1);
});
it("destroys session on transport errors so next call reconnects", async () => {
let factoryCalls = 0;
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
const session = createFakeSession();
if (factoryCalls === 1) {
// First session: transport error (callTool throws)
const callTool = vi.fn(async () => {
throw new Error("connection reset");
});
session.client.callTool = callTool as typeof session.client.callTool;
}
return session;
};
setChromeMcpSessionFactoryForTest(factory);
// First call: transport error — should destroy session
await expect(listChromeMcpTabs("chrome-live")).rejects.toThrow(/connection reset/);
// Second call: should create a new session (factory called twice)
const tabs = await listChromeMcpTabs("chrome-live");
expect(factoryCalls).toBe(2);
expect(tabs).toHaveLength(2);
});
it("creates a fresh session when userDataDir changes for the same profile", async () => {
const createdSessions: ChromeMcpSession[] = [];
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
const factoryCalls: Array<{ profileName: string; userDataDir?: string }> = [];
const factory: ChromeMcpSessionFactory = async (profileName, userDataDir) => {
factoryCalls.push({ profileName, userDataDir });
const session = createFakeSession();
const closeMock = vi.fn().mockResolvedValue(undefined);
session.client.close = closeMock as typeof session.client.close;
createdSessions.push(session);
closeMocks.push(closeMock);
return session;
};
setChromeMcpSessionFactoryForTest(factory);
await listChromeMcpTabs("chrome-live", "/tmp/brave-a");
await listChromeMcpTabs("chrome-live", "/tmp/brave-b");
expect(factoryCalls).toEqual([
{ profileName: "chrome-live", userDataDir: "/tmp/brave-a" },
{ profileName: "chrome-live", userDataDir: "/tmp/brave-b" },
]);
expect(createdSessions).toHaveLength(2);
expect(closeMocks[0]).toHaveBeenCalledTimes(1);
expect(closeMocks[1]).not.toHaveBeenCalled();
});
it("clears failed pending sessions so the next call can retry", async () => {
let factoryCalls = 0;
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
if (factoryCalls === 1) {
throw new Error("attach failed");
}
return createFakeSession();
};
setChromeMcpSessionFactoryForTest(factory);
await expect(listChromeMcpTabs("chrome-live")).rejects.toThrow(/attach failed/);
const tabs = await listChromeMcpTabs("chrome-live");
expect(factoryCalls).toBe(2);
expect(tabs).toHaveLength(2);
});
});