mirror of https://github.com/openclaw/openclaw.git
370 lines
11 KiB
TypeScript
370 lines
11 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { loadConfig } from "../config/config.js";
|
|
import {
|
|
buildChromeMcpLaunchPlanForTest,
|
|
evaluateChromeMcpScript,
|
|
listChromeMcpTabs,
|
|
openChromeMcpTab,
|
|
resetChromeMcpSessionsForTest,
|
|
setChromeMcpSessionFactoryForTest,
|
|
} from "./chrome-mcp.js";
|
|
|
|
vi.mock("../config/config.js", () => ({
|
|
loadConfig: vi.fn(),
|
|
}));
|
|
|
|
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();
|
|
vi.mocked(loadConfig).mockReturnValue({
|
|
browser: {
|
|
profiles: {
|
|
"chrome-live": {
|
|
driver: "existing-session",
|
|
attachOnly: true,
|
|
color: "#00AA00",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("uses autoConnect for desktop existing-session profiles", () => {
|
|
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
|
|
expect(plan.mode).toBe("autoConnect");
|
|
expect(plan.args).toContain("--autoConnect");
|
|
});
|
|
|
|
it("uses headless launch flags for headless existing-session profiles", () => {
|
|
vi.mocked(loadConfig).mockReturnValue({
|
|
browser: {
|
|
headless: true,
|
|
noSandbox: true,
|
|
executablePath: "/usr/bin/google-chrome-stable",
|
|
extraArgs: ["--disable-dev-shm-usage"],
|
|
profiles: {
|
|
"chrome-live": {
|
|
driver: "existing-session",
|
|
attachOnly: true,
|
|
color: "#00AA00",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
|
|
expect(plan.mode).toBe("headless");
|
|
expect(plan.args).toEqual(
|
|
expect.arrayContaining([
|
|
"--headless",
|
|
"--userDataDir",
|
|
expect.stringContaining("/browser/chrome-live/user-data"),
|
|
"--executablePath",
|
|
"/usr/bin/google-chrome-stable",
|
|
"--chromeArg",
|
|
"--no-sandbox",
|
|
"--chromeArg",
|
|
"--disable-setuid-sandbox",
|
|
"--chromeArg",
|
|
"--disable-dev-shm-usage",
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("uses browserUrl for MCP profiles configured with an HTTP target", () => {
|
|
vi.mocked(loadConfig).mockReturnValue({
|
|
browser: {
|
|
profiles: {
|
|
"chrome-live": {
|
|
driver: "existing-session",
|
|
attachOnly: true,
|
|
cdpUrl: "http://127.0.0.1:9222",
|
|
color: "#00AA00",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
|
|
expect(plan.mode).toBe("browserUrl");
|
|
expect(plan.args).toEqual(expect.arrayContaining(["--browserUrl", "http://127.0.0.1:9222"]));
|
|
});
|
|
|
|
it("uses wsEndpoint for MCP profiles configured with a WebSocket target", () => {
|
|
vi.mocked(loadConfig).mockReturnValue({
|
|
browser: {
|
|
profiles: {
|
|
"chrome-live": {
|
|
driver: "existing-session",
|
|
attachOnly: true,
|
|
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc",
|
|
color: "#00AA00",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const plan = buildChromeMcpLaunchPlanForTest("chrome-live");
|
|
expect(plan.mode).toBe("wsEndpoint");
|
|
expect(plan.args).toEqual(
|
|
expect.arrayContaining(["--wsEndpoint", "ws://127.0.0.1:9222/devtools/browser/abc"]),
|
|
);
|
|
});
|
|
|
|
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("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("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);
|
|
});
|
|
});
|