diff --git a/src/browser/server.agent-contract-snapshot-endpoints.test.ts b/src/browser/server.agent-contract-snapshot-endpoints.test.ts index b90924e59..307aa16ca 100644 --- a/src/browser/server.agent-contract-snapshot-endpoints.test.ts +++ b/src/browser/server.agent-contract-snapshot-endpoints.test.ts @@ -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 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(); - 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 { - while (true) { - const port = await new Promise((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", diff --git a/src/browser/server.control-server.test-harness.ts b/src/browser/server.control-server.test-harness.ts new file mode 100644 index 000000000..b8c4dcda2 --- /dev/null +++ b/src/browser/server.control-server.test-harness.ts @@ -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 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(); + 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 { + while (true) { + const port = await new Promise((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 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(); + }); +} diff --git a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts index 8c4612acb..c240e58ef 100644 --- a/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts +++ b/src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts @@ -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 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(); - 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 { - while (true) { - const port = await new Promise((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",