import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { decorateClawdProfile, findChromeExecutableMac, isChromeReachable, stopClawdChrome, } from "./chrome.js"; import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME, } from "./constants.js"; async function readJson(filePath: string): Promise> { const raw = await fsp.readFile(filePath, "utf-8"); return JSON.parse(raw) as Record; } describe("browser chrome profile decoration", () => { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("writes expected name + signed ARGB seed to Chrome prefs", async () => { const userDataDir = await fsp.mkdtemp( path.join(os.tmpdir(), "clawdbot-chrome-test-"), ); try { decorateClawdProfile(userDataDir, { color: DEFAULT_CLAWD_BROWSER_COLOR }); const expectedSignedArgb = ((0xff << 24) | 0xff4500) >> 0; const localState = await readJson(path.join(userDataDir, "Local State")); const profile = localState.profile as Record; const infoCache = profile.info_cache as Record; const def = infoCache.Default as Record; expect(def.name).toBe(DEFAULT_CLAWD_BROWSER_PROFILE_NAME); expect(def.shortcut_name).toBe(DEFAULT_CLAWD_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, ".clawd-profile-decorated"), "utf-8", ); expect(marker.trim()).toMatch(/^\d+$/); } finally { await fsp.rm(userDataDir, { recursive: true, force: true }); } }); it("best-effort writes name when color is invalid", async () => { const userDataDir = await fsp.mkdtemp( path.join(os.tmpdir(), "clawdbot-chrome-test-"), ); try { decorateClawdProfile(userDataDir, { color: "lobster-orange" }); const localState = await readJson(path.join(userDataDir, "Local State")); const profile = localState.profile as Record; const infoCache = profile.info_cache as Record; const def = infoCache.Default as Record; expect(def.name).toBe(DEFAULT_CLAWD_BROWSER_PROFILE_NAME); expect(def.profile_color_seed).toBeUndefined(); } finally { await fsp.rm(userDataDir, { recursive: true, force: true }); } }); it("recovers from missing/invalid preference files", async () => { const userDataDir = await fsp.mkdtemp( path.join(os.tmpdir(), "clawdbot-chrome-test-"), ); try { 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", ); decorateClawdProfile(userDataDir, { color: DEFAULT_CLAWD_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"); } finally { await fsp.rm(userDataDir, { recursive: true, force: true }); } }); it("is idempotent when rerun on an existing profile", async () => { const userDataDir = await fsp.mkdtemp( path.join(os.tmpdir(), "clawdbot-chrome-test-"), ); try { decorateClawdProfile(userDataDir, { color: DEFAULT_CLAWD_BROWSER_COLOR }); decorateClawdProfile(userDataDir, { color: DEFAULT_CLAWD_BROWSER_COLOR }); const prefs = await readJson( path.join(userDataDir, "Default", "Preferences"), ); const profile = prefs.profile as Record; expect(profile.name).toBe(DEFAULT_CLAWD_BROWSER_PROFILE_NAME); } finally { await fsp.rm(userDataDir, { recursive: true, force: true }); } }); }); describe("browser chrome helpers", () => { afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("picks the first existing Chrome candidate on macOS", () => { const exists = vi .spyOn(fs, "existsSync") .mockImplementation((p) => String(p).includes("Google Chrome Canary")); const exe = findChromeExecutableMac(); expect(exe?.kind).toBe("canary"); expect(exe?.path).toMatch(/Google Chrome Canary/); exists.mockRestore(); }); it("returns null when no Chrome candidate exists", () => { const exists = vi.spyOn(fs, "existsSync").mockReturnValue(false); expect(findChromeExecutableMac()).toBeNull(); 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("stopClawdChrome no-ops when process is already killed", async () => { const proc = { killed: true, exitCode: null, kill: vi.fn() }; await stopClawdChrome( { proc, cdpPort: 12345, } as unknown as Parameters[0], 10, ); expect(proc.kill).not.toHaveBeenCalled(); }); it("stopClawdChrome sends SIGTERM and returns once CDP is down", async () => { vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("down"))); const proc = { killed: false, exitCode: null, kill: vi.fn() }; await stopClawdChrome( { proc, cdpPort: 12345, } as unknown as Parameters[0], 10, ); expect(proc.kill).toHaveBeenCalledWith("SIGTERM"); }); });