| 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<typeof stopOpenClawChrome>[0]; |
|
|
| async function readJson(filePath: string): Promise<Record<string, unknown>> { |
| const raw = await fsp.readFile(filePath, "utf-8"); |
| return JSON.parse(raw) as Record<string, unknown>; |
| } |
|
|
| async function readDefaultProfileFromLocalState( |
| userDataDir: string, |
| ): Promise<Record<string, unknown>> { |
| const localState = await readJson(path.join(userDataDir, "Local State")); |
| const profile = localState.profile as Record<string, unknown>; |
| const infoCache = profile.info_cache as Record<string, unknown>; |
| return infoCache.Default as Record<string, unknown>; |
| } |
|
|
| async function withMockChromeCdpServer(params: { |
| wsPath: string; |
| onConnection?: (wss: WebSocketServer) => void; |
| run: (baseUrl: string) => Promise<void>; |
| }) { |
| 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<void>((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<void>((resolve) => wss.close(() => resolve())); |
| await new Promise<void>((resolve) => server.close(() => resolve())); |
| } |
| } |
|
|
| async function stopChromeWithProc(proc: ReturnType<typeof makeChromeTestProc>, 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<string, unknown>; |
| const theme = browser.theme as Record<string, unknown>; |
| const autogenerated = prefs.autogenerated as Record<string, unknown>; |
| const autogeneratedTheme = autogenerated.theme as Record<string, unknown>; |
|
|
| 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"); |
| await fsp.writeFile( |
| path.join(userDataDir, "Default", "Preferences"), |
| "[]", |
| "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<string, unknown>; |
| 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<typeof resolveBrowserExecutableForPlatform>[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", |
| |
| onConnection: (wss) => wss.on("connection", (_ws) => {}), |
| run: async (baseUrl) => { |
| await expect(isChromeCdpReady(baseUrl, 300, 150)).resolves.toBe(false); |
| }, |
| }); |
| }); |
|
|
| it("probes WebSocket URLs via handshake instead of HTTP", async () => { |
| |
| |
| const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called")); |
| vi.stubGlobal("fetch", fetchSpy); |
| |
| await expect(isChromeReachable("ws://127.0.0.1:19999", 50)).resolves.toBe(false); |
| expect(fetchSpy).not.toHaveBeenCalled(); |
| }); |
|
|
| 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"); |
| }); |
| }); |
|
|