refactor(test): share browser control server harness

This commit is contained in:
Peter Steinberger
2026-02-14 16:51:22 +00:00
parent 60898821f7
commit dee3abfcd5
3 changed files with 386 additions and 592 deletions

View File

@@ -1,298 +1,25 @@
import fs from "node:fs/promises";
import { type AddressInfo, createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { fetch as realFetch } from "undici";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
import {
getBrowserControlServerBaseUrl,
getBrowserControlServerTestState,
getCdpMocks,
getPwMocks,
installBrowserControlServerHooks,
startBrowserControlServerFromConfig,
} from "./server.control-server.test-harness.js";
let testPort = 0;
let cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
let prevGatewayPort: string | undefined;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
const chromeUserDataDir = vi.hoisted(() => ({ dir: "/tmp/openclaw" }));
beforeAll(async () => {
chromeUserDataDir.dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-user-data-"));
});
afterAll(async () => {
await fs.rm(chromeUserDataDir.dir, { recursive: true, force: true });
});
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchOpenClawChrome: vi.fn(async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: chromeUserDataDir.dir,
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
}),
resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir),
stopOpenClawChrome: vi.fn(async () => {
reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
await import("./server.js");
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) {
return port;
}
}
}
function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
const state = getBrowserControlServerTestState();
const cdpMocks = getCdpMocks();
const pwMocks = getPwMocks();
describe("browser control server", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) {
return { targetId: createTargetId };
}
throw new Error("cdp disabled");
});
for (const fn of Object.values(pwMocks)) {
fn.mockClear();
}
for (const fn of Object.values(cdpMocks)) {
fn.mockClear();
}
testPort = await getFreePort();
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
if (prevGatewayPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
await stopBrowserControlServer();
});
installBrowserControlServerHooks();
const startServerAndBase = async () => {
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const base = getBrowserControlServerBaseUrl();
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
return base;
};
@@ -326,7 +53,7 @@ describe("browser control server", () => {
expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai");
expect(pwMocks.snapshotAiViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
});
@@ -338,7 +65,7 @@ describe("browser control server", () => {
expect(snapAiZero.format).toBe("ai");
const [lastCall] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? [];
expect(lastCall).toEqual({
cdpUrl: cdpBaseUrl,
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
});
});
@@ -352,7 +79,7 @@ describe("browser control server", () => {
expect(nav.ok).toBe(true);
expect(typeof nav.targetId).toBe("string");
expect(pwMocks.navigateViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
url: "https://example.com",
});
@@ -365,7 +92,7 @@ describe("browser control server", () => {
});
expect(click.ok).toBe(true);
expect(pwMocks.clickViaPlaywright).toHaveBeenNthCalledWith(1, {
cdpUrl: cdpBaseUrl,
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
doubleClick: false,
@@ -390,7 +117,7 @@ describe("browser control server", () => {
});
expect(type.ok).toBe(true);
expect(pwMocks.typeViaPlaywright).toHaveBeenNthCalledWith(1, {
cdpUrl: cdpBaseUrl,
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "1",
text: "",
@@ -404,7 +131,7 @@ describe("browser control server", () => {
});
expect(press.ok).toBe(true);
expect(pwMocks.pressKeyViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
key: "Enter",
});
@@ -415,7 +142,7 @@ describe("browser control server", () => {
});
expect(hover.ok).toBe(true);
expect(pwMocks.hoverViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
});
@@ -426,7 +153,7 @@ describe("browser control server", () => {
});
expect(scroll.ok).toBe(true);
expect(pwMocks.scrollIntoViewViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
ref: "2",
});
@@ -438,7 +165,7 @@ describe("browser control server", () => {
});
expect(drag.ok).toBe(true);
expect(pwMocks.dragViaPlaywright).toHaveBeenCalledWith({
cdpUrl: cdpBaseUrl,
cdpUrl: state.cdpBaseUrl,
targetId: "abcd1234",
startRef: "3",
endRef: "4",

View File

@@ -0,0 +1,337 @@
import fs from "node:fs/promises";
import { type AddressInfo, createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, vi } from "vitest";
type HarnessState = {
testPort: number;
cdpBaseUrl: string;
reachable: boolean;
cfgAttachOnly: boolean;
createTargetId: string | null;
prevGatewayPort: string | undefined;
};
const state: HarnessState = {
testPort: 0,
cdpBaseUrl: "",
reachable: false,
cfgAttachOnly: false,
createTargetId: null,
prevGatewayPort: undefined,
};
export function getBrowserControlServerTestState() {
return state;
}
export function getBrowserControlServerBaseUrl() {
return `http://127.0.0.1:${state.testPort}`;
}
export function setBrowserControlServerCreateTargetId(targetId: string | null) {
state.createTargetId = targetId;
}
export function setBrowserControlServerAttachOnly(attachOnly: boolean) {
state.cfgAttachOnly = attachOnly;
}
export function setBrowserControlServerReachable(reachable: boolean) {
state.reachable = reachable;
}
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn<() => Promise<{ targetId: string }>>(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
export function getCdpMocks() {
return cdpMocks;
}
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
export function getPwMocks() {
return pwMocks;
}
const chromeUserDataDir = vi.hoisted(() => ({ dir: "/tmp/openclaw" }));
beforeAll(async () => {
chromeUserDataDir.dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-user-data-"));
});
afterAll(async () => {
await fs.rm(chromeUserDataDir.dir, { recursive: true, force: true });
});
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
color: "#FF4500",
attachOnly: state.cfgAttachOnly,
headless: true,
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: state.testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
export function getLaunchCalls() {
return launchCalls;
}
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => state.reachable),
isChromeReachable: vi.fn(async () => state.reachable),
launchOpenClawChrome: vi.fn(async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
state.reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: chromeUserDataDir.dir,
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
}),
resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir),
stopOpenClawChrome: vi.fn(async () => {
state.reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, cdpPath: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = cdpPath.startsWith("/") ? cdpPath : `/${cdpPath}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
const server = await import("./server.js");
export const startBrowserControlServerFromConfig = server.startBrowserControlServerFromConfig;
export const stopBrowserControlServer = server.stopBrowserControlServer;
export async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) {
return port;
}
}
}
export function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
function mockClearAll(obj: Record<string, { mockClear: () => unknown }>) {
for (const fn of Object.values(obj)) {
fn.mockClear();
}
}
export function installBrowserControlServerHooks() {
beforeEach(async () => {
state.reachable = false;
state.cfgAttachOnly = false;
state.createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (state.createTargetId) {
return { targetId: state.createTargetId };
}
throw new Error("cdp disabled");
});
mockClearAll(pwMocks);
mockClearAll(cdpMocks);
state.testPort = await getFreePort();
state.cdpBaseUrl = `http://127.0.0.1:${state.testPort + 1}`;
state.prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(state.testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!state.reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
if (state.prevGatewayPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = state.prevGatewayPort;
}
await stopBrowserControlServer();
});
}

View File

@@ -1,297 +1,27 @@
import fs from "node:fs/promises";
import { type AddressInfo, createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { fetch as realFetch } from "undici";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getBrowserControlServerBaseUrl,
getBrowserControlServerTestState,
getCdpMocks,
getFreePort,
installBrowserControlServerHooks,
makeResponse,
getPwMocks,
startBrowserControlServerFromConfig,
stopBrowserControlServer,
} from "./server.control-server.test-harness.js";
let testPort = 0;
let _cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
let prevGatewayPort: string | undefined;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
const chromeUserDataDir = vi.hoisted(() => ({ dir: "/tmp/openclaw" }));
beforeAll(async () => {
chromeUserDataDir.dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-user-data-"));
});
afterAll(async () => {
await fs.rm(chromeUserDataDir.dir, { recursive: true, force: true });
});
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchOpenClawChrome: vi.fn(async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: chromeUserDataDir.dir,
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
}),
resolveOpenClawUserDataDir: vi.fn(() => chromeUserDataDir.dir),
stopOpenClawChrome: vi.fn(async () => {
reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
await import("./server.js");
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) {
return port;
}
}
}
function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
const state = getBrowserControlServerTestState();
const cdpMocks = getCdpMocks();
const pwMocks = getPwMocks();
describe("browser control server", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) {
return { targetId: createTargetId };
}
throw new Error("cdp disabled");
});
for (const fn of Object.values(pwMocks)) {
fn.mockClear();
}
for (const fn of Object.values(cdpMocks)) {
fn.mockClear();
}
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
if (prevGatewayPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
await stopBrowserControlServer();
});
installBrowserControlServerHooks();
it("POST /tabs/open?profile=unknown returns 404", async () => {
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const base = getBrowserControlServerBaseUrl();
const result = await realFetch(`${base}/tabs/open?profile=unknown`, {
method: "POST",
@@ -306,8 +36,8 @@ describe("browser control server", () => {
describe("profile CRUD endpoints", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
state.reachable = false;
state.cfgAttachOnly = false;
for (const fn of Object.values(pwMocks)) {
fn.mockClear();
@@ -316,10 +46,10 @@ describe("profile CRUD endpoints", () => {
fn.mockClear();
}
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2);
state.testPort = await getFreePort();
state.cdpBaseUrl = `http://127.0.0.1:${state.testPort + 1}`;
state.prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(state.testPort - 2);
vi.stubGlobal(
"fetch",
@@ -336,17 +66,17 @@ describe("profile CRUD endpoints", () => {
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
if (prevGatewayPort === undefined) {
if (state.prevGatewayPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
process.env.OPENCLAW_GATEWAY_PORT = state.prevGatewayPort;
}
await stopBrowserControlServer();
});
it("validates profile create/delete endpoints", async () => {
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const base = getBrowserControlServerBaseUrl();
const createMissingName = await realFetch(`${base}/profiles/create`, {
method: "POST",