import fs from "node:fs"; import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { decorateOpenClawProfile, ensureProfileCleanExit, findChromeExecutableMac, findChromeExecutableWindows, isChromeReachable, resolveBrowserExecutableForPlatform, stopOpenClawChrome, } from "./chrome.js"; import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_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; } 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; } 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))); } function makeProc(overrides?: Partial<{ killed: boolean; exitCode: number | null }>) { return { killed: overrides?.killed ?? false, exitCode: overrides?.exitCode ?? null, kill: vi.fn(), }; } 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("stopOpenClawChrome no-ops when process is already killed", async () => { const proc = makeProc({ killed: true }); await stopOpenClawChrome( { proc, cdpPort: 12345, } as unknown as Parameters[0], 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 = makeProc(); await stopOpenClawChrome( { proc, cdpPort: 12345, } as unknown as Parameters[0], 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 = makeProc(); await stopOpenClawChrome( { proc, cdpPort: 12345, } as unknown as Parameters[0], 1, ); expect(proc.kill).toHaveBeenNthCalledWith(1, "SIGTERM"); expect(proc.kill).toHaveBeenNthCalledWith(2, "SIGKILL"); }); });