import fs from "node:fs"; import fsp from "node:fs/promises"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { WebSocketServer } from "ws"; import { decorateOpenClawProfile, ensureProfileCleanExit, findChromeExecutableMac, findChromeExecutableWindows, isChromeCdpReady, isChromeReachable, resolveBrowserExecutableForPlatform, stopOpenClawChrome, } from "./chrome.js"; import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, } from "./constants.js"; type StopChromeTarget = Parameters[0]; async function readJson(filePath: string): Promise> { const raw = await fsp.readFile(filePath, "utf-8"); return JSON.parse(raw) as Record; } async function readDefaultProfileFromLocalState( userDataDir: string, ): Promise> { const localState = await readJson(path.join(userDataDir, "Local State")); const profile = localState.profile as Record; const infoCache = profile.info_cache as Record; return infoCache.Default as Record; } async function withMockChromeCdpServer(params: { wsPath: string; onConnection?: (wss: WebSocketServer) => void; run: (baseUrl: string) => Promise; }) { const server = createServer((req, res) => { if (req.url === "/json/version") { const addr = server.address() as AddressInfo; res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ webSocketDebuggerUrl: `ws://127.0.0.1:${addr.port}${params.wsPath}`, }), ); return; } res.writeHead(404); res.end(); }); const wss = new WebSocketServer({ noServer: true }); server.on("upgrade", (req, socket, head) => { if (req.url !== params.wsPath) { socket.destroy(); return; } wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); }); }); params.onConnection?.(wss); await new Promise((resolve, reject) => { server.listen(0, "127.0.0.1", () => resolve()); server.once("error", reject); }); try { const addr = server.address() as AddressInfo; await params.run(`http://127.0.0.1:${addr.port}`); } finally { await new Promise((resolve) => wss.close(() => resolve())); await new Promise((resolve) => server.close(() => resolve())); } } async function stopChromeWithProc(proc: ReturnType, timeoutMs: number) { await stopOpenClawChrome( { proc, cdpPort: 12345, } as unknown as StopChromeTarget, timeoutMs, ); } function makeChromeTestProc(overrides?: Partial<{ killed: boolean; exitCode: number | null }>) { return { killed: overrides?.killed ?? false, exitCode: overrides?.exitCode ?? null, kill: vi.fn(), }; } describe("browser chrome profile decoration", () => { let fixtureRoot = ""; let fixtureCount = 0; const createUserDataDir = async () => { const dir = path.join(fixtureRoot, `profile-${fixtureCount++}`); await fsp.mkdir(dir, { recursive: true }); return dir; }; beforeAll(async () => { fixtureRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "openclaw-chrome-suite-")); }); afterAll(async () => { if (fixtureRoot) { await fsp.rm(fixtureRoot, { recursive: true, force: true }); } }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("writes expected name + signed ARGB seed to Chrome prefs", async () => { const userDataDir = await createUserDataDir(); decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0; const def = await readDefaultProfileFromLocalState(userDataDir); expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); expect(def.shortcut_name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); expect(def.profile_color_seed).toBe(expectedSignedArgb); expect(def.profile_highlight_color).toBe(expectedSignedArgb); expect(def.default_avatar_fill_color).toBe(expectedSignedArgb); expect(def.default_avatar_stroke_color).toBe(expectedSignedArgb); const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); const browser = prefs.browser as Record; const theme = browser.theme as Record; const autogenerated = prefs.autogenerated as Record; const autogeneratedTheme = autogenerated.theme as Record; expect(theme.user_color2).toBe(expectedSignedArgb); expect(autogeneratedTheme.color).toBe(expectedSignedArgb); const marker = await fsp.readFile( path.join(userDataDir, ".openclaw-profile-decorated"), "utf-8", ); expect(marker.trim()).toMatch(/^\d+$/); }); it("best-effort writes name when color is invalid", async () => { const userDataDir = await createUserDataDir(); decorateOpenClawProfile(userDataDir, { color: "lobster-orange" }); const def = await readDefaultProfileFromLocalState(userDataDir); expect(def.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); expect(def.profile_color_seed).toBeUndefined(); }); it("recovers from missing/invalid preference files", async () => { const userDataDir = await createUserDataDir(); await fsp.mkdir(path.join(userDataDir, "Default"), { recursive: true }); await fsp.writeFile(path.join(userDataDir, "Local State"), "{", "utf-8"); // invalid JSON await fsp.writeFile( path.join(userDataDir, "Default", "Preferences"), "[]", // valid JSON but wrong shape "utf-8", ); decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); const localState = await readJson(path.join(userDataDir, "Local State")); expect(typeof localState.profile).toBe("object"); const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); expect(typeof prefs.profile).toBe("object"); }); it("writes clean exit prefs to avoid restore prompts", async () => { const userDataDir = await createUserDataDir(); ensureProfileCleanExit(userDataDir); const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); expect(prefs.exit_type).toBe("Normal"); expect(prefs.exited_cleanly).toBe(true); }); it("is idempotent when rerun on an existing profile", async () => { const userDataDir = await createUserDataDir(); decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR }); const prefs = await readJson(path.join(userDataDir, "Default", "Preferences")); const profile = prefs.profile as Record; expect(profile.name).toBe(DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME); }); }); describe("browser chrome helpers", () => { function mockExistsSync(match: (pathValue: string) => boolean) { return vi.spyOn(fs, "existsSync").mockImplementation((p) => match(String(p))); } afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("picks the first existing Chrome candidate on macOS", () => { const exists = mockExistsSync((pathValue) => pathValue.includes("Google Chrome.app/Contents/MacOS/Google Chrome"), ); const exe = findChromeExecutableMac(); expect(exe?.kind).toBe("chrome"); expect(exe?.path).toMatch(/Google Chrome\.app/); exists.mockRestore(); }); it("returns null when no Chrome candidate exists", () => { const exists = vi.spyOn(fs, "existsSync").mockReturnValue(false); expect(findChromeExecutableMac()).toBeNull(); exists.mockRestore(); }); it("picks the first existing Chrome candidate on Windows", () => { vi.stubEnv("LOCALAPPDATA", "C:\\Users\\Test\\AppData\\Local"); const exists = mockExistsSync((pathStr) => { return ( pathStr.includes("Google\\Chrome\\Application\\chrome.exe") || pathStr.includes("BraveSoftware\\Brave-Browser\\Application\\brave.exe") || pathStr.includes("Microsoft\\Edge\\Application\\msedge.exe") ); }); const exe = findChromeExecutableWindows(); expect(exe?.kind).toBe("chrome"); expect(exe?.path).toMatch(/chrome\.exe$/); exists.mockRestore(); }); it("finds Chrome in Program Files on Windows", () => { const marker = path.win32.join("Program Files", "Google", "Chrome"); const exists = mockExistsSync((pathValue) => pathValue.includes(marker)); const exe = findChromeExecutableWindows(); expect(exe?.kind).toBe("chrome"); expect(exe?.path).toMatch(/chrome\.exe$/); exists.mockRestore(); }); it("returns null when no Chrome candidate exists on Windows", () => { const exists = vi.spyOn(fs, "existsSync").mockReturnValue(false); expect(findChromeExecutableWindows()).toBeNull(); exists.mockRestore(); }); it("resolves Windows executables without LOCALAPPDATA", () => { vi.stubEnv("LOCALAPPDATA", ""); vi.stubEnv("ProgramFiles", "C:\\Program Files"); vi.stubEnv("ProgramFiles(x86)", "C:\\Program Files (x86)"); const marker = path.win32.join( "Program Files", "Google", "Chrome", "Application", "chrome.exe", ); const exists = mockExistsSync((pathValue) => pathValue.includes(marker)); const exe = resolveBrowserExecutableForPlatform( {} as Parameters[0], "win32", ); expect(exe?.kind).toBe("chrome"); expect(exe?.path).toMatch(/chrome\.exe$/); exists.mockRestore(); }); it("reports reachability based on /json/version", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), } as unknown as Response), ); await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(true); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: false, json: async () => ({}), } as unknown as Response), ); await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false); vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom"))); await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false); }); it("reports cdpReady only when Browser.getVersion command succeeds", async () => { await withMockChromeCdpServer({ wsPath: "/devtools/browser/health", onConnection: (wss) => { wss.on("connection", (ws) => { ws.on("message", (raw) => { let message: { id?: unknown; method?: unknown } | null = null; try { const text = typeof raw === "string" ? raw : Buffer.isBuffer(raw) ? raw.toString("utf8") : Array.isArray(raw) ? Buffer.concat(raw).toString("utf8") : Buffer.from(raw).toString("utf8"); message = JSON.parse(text) as { id?: unknown; method?: unknown }; } catch { return; } if (message?.method === "Browser.getVersion" && message.id === 1) { ws.send( JSON.stringify({ id: 1, result: { product: "Chrome/Mock" }, }), ); } }); }); }, run: async (baseUrl) => { await expect(isChromeCdpReady(baseUrl, 300, 400)).resolves.toBe(true); }, }); }); it("reports cdpReady false when websocket opens but command channel is stale", async () => { await withMockChromeCdpServer({ wsPath: "/devtools/browser/stale", // Simulate a stale command channel: WS opens but never responds to commands. onConnection: (wss) => wss.on("connection", (_ws) => {}), run: async (baseUrl) => { await expect(isChromeCdpReady(baseUrl, 300, 150)).resolves.toBe(false); }, }); }); it("stopOpenClawChrome no-ops when process is already killed", async () => { const proc = makeChromeTestProc({ killed: true }); await stopChromeWithProc(proc, 10); expect(proc.kill).not.toHaveBeenCalled(); }); it("stopOpenClawChrome sends SIGTERM and returns once CDP is down", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("down"))); const proc = makeChromeTestProc(); await stopChromeWithProc(proc, 10); expect(proc.kill).toHaveBeenCalledWith("SIGTERM"); }); it("stopOpenClawChrome escalates to SIGKILL when CDP stays reachable", async () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), } as unknown as Response), ); const proc = makeChromeTestProc(); await stopChromeWithProc(proc, 1); expect(proc.kill).toHaveBeenNthCalledWith(1, "SIGTERM"); expect(proc.kill).toHaveBeenNthCalledWith(2, "SIGKILL"); }); });