diff --git a/src/agents/index.test.ts b/src/agents/index.test.ts new file mode 100644 index 000000000..40f228335 --- /dev/null +++ b/src/agents/index.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; + +import { getAgentSpec } from "./index.js"; + +describe("agents index", () => { + it("returns a spec for pi", () => { + const spec = getAgentSpec("pi"); + expect(spec).toBeTruthy(); + expect(spec.kind).toBe("pi"); + expect(typeof spec.parseOutput).toBe("function"); + }); +}); diff --git a/src/agents/pi-path.test.ts b/src/agents/pi-path.test.ts new file mode 100644 index 000000000..a98ef893d --- /dev/null +++ b/src/agents/pi-path.test.ts @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { resolveBundledPiBinary } from "./pi-path.js"; + +describe("pi-path", () => { + it("resolves to a bundled binary path when available", () => { + const resolved = resolveBundledPiBinary(); + expect(resolved === null || typeof resolved === "string").toBe(true); + if (typeof resolved === "string") { + expect(resolved).toMatch(/pi-coding-agent/); + expect(resolved).toMatch(/dist\/pi|dist\/cli\.js|bin\/tau-dev\.mjs/); + } + }); + + it("prefers dist/pi when present (branch coverage)", () => { + const original = fs.existsSync.bind(fs); + const spy = vi.spyOn(fs, "existsSync").mockImplementation((p) => { + const s = String(p); + if (s.endsWith(path.join("dist", "pi"))) return true; + return original(p); + }); + try { + const resolved = resolveBundledPiBinary(); + expect(resolved).not.toBeNull(); + expect(typeof resolved).toBe("string"); + expect(resolved).toMatch(/dist\/pi$/); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/auto-reply/tool-meta.test.ts b/src/auto-reply/tool-meta.test.ts new file mode 100644 index 000000000..98ff2f3a9 --- /dev/null +++ b/src/auto-reply/tool-meta.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createToolDebouncer, + formatToolAggregate, + formatToolPrefix, + shortenMeta, + shortenPath, +} from "./tool-meta.js"; + +describe("tool meta formatting", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + it("shortens paths under HOME", () => { + vi.stubEnv("HOME", "/Users/test"); + expect(shortenPath("/Users/test")).toBe("~"); + expect(shortenPath("/Users/test/a/b.txt")).toBe("~/a/b.txt"); + expect(shortenPath("/opt/x")).toBe("/opt/x"); + }); + + it("shortens meta strings with optional colon suffix", () => { + vi.stubEnv("HOME", "/Users/test"); + expect(shortenMeta("/Users/test/a.txt")).toBe("~/a.txt"); + expect(shortenMeta("/Users/test/a.txt:12")).toBe("~/a.txt:12"); + expect(shortenMeta("")).toBe(""); + }); + + it("formats aggregates with grouping and brace-collapse", () => { + vi.stubEnv("HOME", "/Users/test"); + const out = formatToolAggregate(" fs ", [ + "/Users/test/dir/a.txt", + "/Users/test/dir/b.txt", + "note", + "a→b", + ]); + expect(out).toMatch(/^\[🛠️ fs]/); + expect(out).toContain("~/dir/{a.txt, b.txt}"); + expect(out).toContain("note"); + expect(out).toContain("a→b"); + }); + + it("formats prefixes with default labels", () => { + vi.stubEnv("HOME", "/Users/test"); + expect(formatToolPrefix(undefined, undefined)).toBe("[🛠️ tool]"); + expect(formatToolPrefix("x", "/Users/test/a.txt")).toBe("[🛠️ x ~/a.txt]"); + }); +}); + +describe("tool meta debouncer", () => { + it("flushes on timer and when tool changes", () => { + vi.useFakeTimers(); + try { + const calls: Array<{ tool: string | undefined; metas: string[] }> = []; + const d = createToolDebouncer((tool, metas) => { + calls.push({ tool, metas }); + }, 50); + + d.push("a", "/tmp/1"); + d.push("a", "/tmp/2"); + expect(calls).toHaveLength(0); + + vi.advanceTimersByTime(60); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + tool: "a", + metas: ["/tmp/1", "/tmp/2"], + }); + + d.push("a", "x"); + d.push("b", "y"); // tool change flushes immediately + expect(calls).toHaveLength(2); + expect(calls[1]).toMatchObject({ tool: "a", metas: ["x"] }); + + vi.advanceTimersByTime(60); + expect(calls).toHaveLength(3); + expect(calls[2]).toMatchObject({ tool: "b", metas: ["y"] }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/browser/chrome.test.ts b/src/browser/chrome.test.ts new file mode 100644 index 000000000..5c8c0ed8a --- /dev/null +++ b/src/browser/chrome.test.ts @@ -0,0 +1,199 @@ +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(), "clawdis-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(), "clawdis-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(), "clawdis-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(), "clawdis-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 } as unknown as Response), + ); + await expect(isChromeReachable(12345, 50)).resolves.toBe(true); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false } as unknown as Response), + ); + await expect(isChromeReachable(12345, 50)).resolves.toBe(false); + + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("boom"))); + await expect(isChromeReachable(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"); + }); +}); diff --git a/src/browser/client.test.ts b/src/browser/client.test.ts index da51cb224..fbcf1fd84 100644 --- a/src/browser/client.test.ts +++ b/src/browser/client.test.ts @@ -1,6 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { browserStatus } from "./client.js"; +import { + browserClickRef, + browserDom, + browserEval, + browserOpenTab, + browserQuery, + browserScreenshot, + browserSnapshot, + browserStatus, + browserTabs, +} from "./client.js"; describe("browser client", () => { afterEach(() => { @@ -21,4 +31,157 @@ describe("browser client", () => { /Start .*gateway/i, ); }); + + it("adds useful timeout messaging for abort-like failures", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("aborted"))); + await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow( + /timed out/i, + ); + }); + + it("surfaces non-2xx responses with body text", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: async () => "conflict", + } as unknown as Response), + ); + + await expect( + browserEval("http://127.0.0.1:18791", { js: "1+1" }), + ).rejects.toThrow(/409: conflict/i); + }); + + it("uses the expected endpoints + methods for common calls", async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ url, init }); + if (url.endsWith("/tabs") && (!init || init.method === undefined)) { + return { + ok: true, + json: async () => ({ + running: true, + tabs: [{ targetId: "t1", title: "T", url: "https://x" }], + }), + } as unknown as Response; + } + if (url.endsWith("/tabs/open")) { + return { + ok: true, + json: async () => ({ + targetId: "t2", + title: "N", + url: "https://y", + }), + } as unknown as Response; + } + if (url.includes("/screenshot")) { + return { + ok: true, + json: async () => ({ + ok: true, + path: "/tmp/a.png", + targetId: "t1", + url: "https://x", + }), + } as unknown as Response; + } + if (url.includes("/query?")) { + return { + ok: true, + json: async () => ({ + ok: true, + targetId: "t1", + url: "https://x", + matches: [{ index: 0, tag: "a" }], + }), + } as unknown as Response; + } + if (url.includes("/dom?")) { + return { + ok: true, + json: async () => ({ + ok: true, + targetId: "t1", + url: "https://x", + format: "text", + text: "hi", + }), + } as unknown as Response; + } + if (url.includes("/snapshot?")) { + return { + ok: true, + json: async () => ({ + ok: true, + format: "aria", + targetId: "t1", + url: "https://x", + nodes: [], + }), + } as unknown as Response; + } + if (url.endsWith("/click")) { + return { + ok: true, + json: async () => ({ ok: true, targetId: "t1", url: "https://x" }), + } as unknown as Response; + } + return { + ok: true, + json: async () => ({ + enabled: true, + controlUrl: "http://127.0.0.1:18791", + running: true, + pid: 1, + cdpPort: 18792, + chosenBrowser: "chrome", + userDataDir: "/tmp", + color: "#FF4500", + headless: false, + attachOnly: false, + }), + } as unknown as Response; + }), + ); + + await expect( + browserStatus("http://127.0.0.1:18791"), + ).resolves.toMatchObject({ + running: true, + cdpPort: 18792, + }); + + await expect(browserTabs("http://127.0.0.1:18791")).resolves.toHaveLength( + 1, + ); + await expect( + browserOpenTab("http://127.0.0.1:18791", "https://example.com"), + ).resolves.toMatchObject({ targetId: "t2" }); + + await expect( + browserScreenshot("http://127.0.0.1:18791", { fullPage: true }), + ).resolves.toMatchObject({ ok: true, path: "/tmp/a.png" }); + await expect( + browserQuery("http://127.0.0.1:18791", { selector: "a", limit: 1 }), + ).resolves.toMatchObject({ ok: true }); + await expect( + browserDom("http://127.0.0.1:18791", { format: "text", maxChars: 10 }), + ).resolves.toMatchObject({ ok: true }); + await expect( + browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }), + ).resolves.toMatchObject({ ok: true, format: "aria" }); + await expect( + browserClickRef("http://127.0.0.1:18791", { ref: "1" }), + ).resolves.toMatchObject({ ok: true }); + + expect(calls.some((c) => c.url.endsWith("/tabs"))).toBe(true); + const open = calls.find((c) => c.url.endsWith("/tabs/open")); + expect(open?.init?.method).toBe("POST"); + }); }); diff --git a/src/browser/server.test.ts b/src/browser/server.test.ts new file mode 100644 index 000000000..55773520d --- /dev/null +++ b/src/browser/server.test.ts @@ -0,0 +1,431 @@ +import { type AddressInfo, createServer } from "node:net"; +import { fetch as realFetch } from "undici"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let testPort = 0; +let reachable = false; +let cfgAttachOnly = false; +let createTargetId: string | null = null; +let screenshotThrowsOnce = false; + +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", () => ({ + loadConfig: () => ({ + browser: { + enabled: true, + controlUrl: `http://127.0.0.1:${testPort}`, + color: "#FF4500", + attachOnly: cfgAttachOnly, + headless: true, + }, + }), +})); + +const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>); +vi.mock("./chrome.js", () => ({ + isChromeReachable: vi.fn(async () => reachable), + launchClawdChrome: vi.fn(async (resolved: { cdpPort: number }) => { + launchCalls.push({ port: resolved.cdpPort }); + reachable = true; + return { + pid: 123, + exe: { kind: "chrome", path: "/fake/chrome" }, + userDataDir: "/tmp/clawd", + cdpPort: resolved.cdpPort, + startedAt: Date.now(), + proc, + }; + }), + stopClawdChrome: vi.fn(async () => { + reachable = false; + }), +})); + +const evalCalls = vi.hoisted(() => [] as Array); +let evalThrows = false; +vi.mock("./cdp.js", () => ({ + createTargetViaCdp: vi.fn(async () => { + if (createTargetId) return { targetId: createTargetId }; + throw new Error("cdp disabled"); + }), + evaluateJavaScript: vi.fn(async ({ expression }: { expression: string }) => { + evalCalls.push(expression); + if (evalThrows) { + return { + exceptionDetails: { text: "boom" }, + }; + } + return { result: { type: "string", value: "ok" } }; + }), + getDomText: vi.fn(async () => ({ text: "" })), + querySelector: vi.fn(async () => ({ matches: [{ index: 0, tag: "a" }] })), + snapshotAria: vi.fn(async () => ({ + nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }], + })), + snapshotDom: vi.fn(async () => ({ + nodes: [{ ref: "1", parentRef: null, depth: 0, tag: "html" }], + })), + captureScreenshot: vi.fn(async () => { + if (screenshotThrowsOnce) { + screenshotThrowsOnce = false; + throw new Error("jpeg failed"); + } + return Buffer.from("jpg"); + }), + captureScreenshotPng: vi.fn(async () => Buffer.from("png")), +})); + +vi.mock("./pw-ai.js", () => ({ + clickRefViaPlaywright: vi.fn(async () => {}), + closePlaywrightBrowserConnection: vi.fn(async () => {}), + snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })), +})); + +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", + })), +})); + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const s = createServer(); + s.once("error", reject); + s.listen(0, "127.0.0.1", () => { + const port = (s.address() as AddressInfo).port; + s.close((err) => (err ? reject(err) : resolve(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; +} + +describe("browser control server", () => { + beforeEach(async () => { + reachable = false; + cfgAttachOnly = false; + createTargetId = null; + screenshotThrowsOnce = false; + testPort = await getFreePort(); + + // 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(); + const { stopBrowserControlServer } = await import("./server.js"); + await stopBrowserControlServer(); + }); + + it("serves status + starts browser when requested", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + const started = await startBrowserControlServerFromConfig(); + expect(started?.port).toBe(testPort); + + const base = `http://127.0.0.1:${testPort}`; + const s1 = (await realFetch(`${base}/`).then((r) => r.json())) as { + running: boolean; + pid: number | null; + }; + expect(s1.running).toBe(false); + expect(s1.pid).toBe(null); + + await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json()); + const s2 = (await realFetch(`${base}/`).then((r) => r.json())) as { + running: boolean; + pid: number | null; + chosenBrowser: string | null; + }; + expect(s2.running).toBe(true); + expect(s2.pid).toBe(123); + expect(s2.chosenBrowser).toBe("chrome"); + expect(launchCalls.length).toBeGreaterThan(0); + }); + + it("handles tabs: list, open, focus conflict on ambiguous prefix", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + + await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json()); + const tabs = (await realFetch(`${base}/tabs`).then((r) => r.json())) as { + running: boolean; + tabs: Array<{ targetId: string }>; + }; + expect(tabs.running).toBe(true); + expect(tabs.tabs.length).toBeGreaterThan(0); + + const opened = await realFetch(`${base}/tabs/open`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com" }), + }).then((r) => r.json()); + expect(opened).toMatchObject({ targetId: "newtab1" }); + + const focus = await realFetch(`${base}/tabs/focus`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetId: "abc" }), + }); + expect(focus.status).toBe(409); + }); + + it("maps JS exceptions to a 400 and returns results otherwise", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json()); + + evalThrows = true; + const bad = await realFetch(`${base}/eval`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ js: "throw 1" }), + }); + expect(bad.status).toBe(400); + + evalThrows = false; + const ok = (await realFetch(`${base}/eval`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ js: "1+1", await: true }), + }).then((r) => r.json())) as { ok: boolean; result?: unknown }; + expect(ok.ok).toBe(true); + expect(evalCalls.length).toBeGreaterThan(0); + }); + + it("supports query/dom/snapshot/click/screenshot and stop", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json()); + + const query = (await realFetch(`${base}/query?selector=a&limit=1`).then( + (r) => r.json(), + )) as { ok: boolean; matches?: unknown[] }; + expect(query.ok).toBe(true); + expect(Array.isArray(query.matches)).toBe(true); + + const dom = (await realFetch(`${base}/dom?format=text&maxChars=10`).then( + (r) => r.json(), + )) as { ok: boolean; text?: string }; + expect(dom.ok).toBe(true); + expect(typeof dom.text).toBe("string"); + + const snapAria = (await realFetch( + `${base}/snapshot?format=aria&limit=1`, + ).then((r) => r.json())) as { + ok: boolean; + format?: string; + nodes?: unknown[]; + }; + expect(snapAria.ok).toBe(true); + expect(snapAria.format).toBe("aria"); + + const snapAi = (await realFetch(`${base}/snapshot?format=ai`).then((r) => + r.json(), + )) as { ok: boolean; format?: string; snapshot?: string }; + expect(snapAi.ok).toBe(true); + expect(snapAi.format).toBe("ai"); + + const click = (await realFetch(`${base}/click`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ref: "1" }), + }).then((r) => r.json())) as { ok: boolean }; + expect(click.ok).toBe(true); + + const shot = (await realFetch(`${base}/screenshot?fullPage=true`).then( + (r) => r.json(), + )) as { ok: boolean; path?: string }; + expect(shot.ok).toBe(true); + expect(typeof shot.path).toBe("string"); + + const stopped = (await realFetch(`${base}/stop`, { + method: "POST", + }).then((r) => r.json())) as { ok: boolean; stopped?: boolean }; + expect(stopped.ok).toBe(true); + expect(stopped.stopped).toBe(true); + }); + + it("covers common error branches", async () => { + cfgAttachOnly = true; + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + + const missing = await realFetch(`${base}/tabs/open`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(missing.status).toBe(400); + + reachable = false; + const started = (await realFetch(`${base}/start`, { + method: "POST", + }).then((r) => r.json())) as { error?: string }; + expect(started.error ?? "").toMatch(/attachOnly/i); + }); + + it("opens tabs via CDP createTarget path and falls back to PNG screenshots", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json()); + + createTargetId = "abcd1234"; + const opened = (await realFetch(`${base}/tabs/open`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com" }), + }).then((r) => r.json())) as { targetId?: string }; + expect(opened.targetId).toBe("abcd1234"); + + screenshotThrowsOnce = true; + const shot = (await realFetch(`${base}/screenshot`).then((r) => + r.json(), + )) as { ok: boolean; path?: string }; + expect(shot.ok).toBe(true); + expect(typeof shot.path).toBe("string"); + }); + + it("covers additional endpoint branches", async () => { + const { startBrowserControlServerFromConfig } = await import("./server.js"); + await startBrowserControlServerFromConfig(); + const base = `http://127.0.0.1:${testPort}`; + + const tabsWhenStopped = (await realFetch(`${base}/tabs`).then((r) => + r.json(), + )) as { running: boolean; tabs: unknown[] }; + expect(tabsWhenStopped.running).toBe(false); + expect(Array.isArray(tabsWhenStopped.tabs)).toBe(true); + + const focusStopped = await realFetch(`${base}/tabs/focus`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetId: "abcd" }), + }); + expect(focusStopped.status).toBe(409); + + await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json()); + + const focusMissing = await realFetch(`${base}/tabs/focus`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetId: "zzz" }), + }); + expect(focusMissing.status).toBe(404); + + const delAmbiguous = await realFetch(`${base}/tabs/abc`, { + method: "DELETE", + }); + expect(delAmbiguous.status).toBe(409); + + const shotAmbiguous = await realFetch(`${base}/screenshot?targetId=abc`); + expect(shotAmbiguous.status).toBe(409); + + const evalMissing = await realFetch(`${base}/eval`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(evalMissing.status).toBe(400); + + const queryMissing = await realFetch(`${base}/query`); + expect(queryMissing.status).toBe(400); + + const snapDom = (await realFetch( + `${base}/snapshot?format=domSnapshot&limit=1`, + ).then((r) => r.json())) as { ok: boolean; format?: string }; + expect(snapDom.ok).toBe(true); + expect(snapDom.format).toBe("domSnapshot"); + }); +}); diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts new file mode 100644 index 000000000..a6684c92d --- /dev/null +++ b/src/cli/gateway-cli.coverage.test.ts @@ -0,0 +1,177 @@ +import { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; + +const callGateway = vi.fn(async () => ({ ok: true })); +const randomIdempotencyKey = vi.fn(() => "rk_test"); +const startGatewayServer = vi.fn(async () => ({ + close: vi.fn(async () => {}), +})); +const setVerbose = vi.fn(); +const createDefaultDeps = vi.fn(); +const forceFreePort = vi.fn(() => []); + +const runtimeLogs: string[] = []; +const runtimeErrors: string[] = []; +const defaultRuntime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (msg: string) => runtimeErrors.push(msg), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, +}; + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGateway(opts), + randomIdempotencyKey: () => randomIdempotencyKey(), +})); + +vi.mock("../gateway/server.js", () => ({ + startGatewayServer: (port: number, opts: unknown) => + startGatewayServer(port, opts), +})); + +vi.mock("../globals.js", () => ({ + info: (msg: string) => msg, + setVerbose: (enabled: boolean) => setVerbose(enabled), +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +vi.mock("./deps.js", () => ({ + createDefaultDeps: () => createDefaultDeps(), +})); + +vi.mock("./ports.js", () => ({ + forceFreePort: () => forceFreePort(), +})); + +describe("gateway-cli coverage", () => { + it("registers call/health/status/send/agent commands and routes to callGateway", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + + const { registerGatewayCli } = await import("./gateway-cli.js"); + const program = new Command(); + program.exitOverride(); + registerGatewayCli(program); + + await program.parseAsync( + ["gateway", "call", "health", "--params", '{"x":1}'], + { from: "user" }, + ); + + expect(callGateway).toHaveBeenCalledTimes(1); + expect(runtimeLogs.join("\n")).toContain('"ok": true'); + }); + + it("fails gateway call on invalid params JSON", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + + const { registerGatewayCli } = await import("./gateway-cli.js"); + const program = new Command(); + program.exitOverride(); + registerGatewayCli(program); + + await expect( + program.parseAsync( + ["gateway", "call", "status", "--params", "not-json"], + { from: "user" }, + ), + ).rejects.toThrow("__exit__:1"); + + expect(callGateway).not.toHaveBeenCalled(); + expect(runtimeErrors.join("\n")).toContain("Gateway call failed:"); + }); + + it("fills idempotency keys for send/agent when missing", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + callGateway.mockClear(); + randomIdempotencyKey.mockClear(); + + const { registerGatewayCli } = await import("./gateway-cli.js"); + const program = new Command(); + program.exitOverride(); + registerGatewayCli(program); + + await program.parseAsync( + ["gateway", "send", "--to", "+1555", "--message", "hi"], + { from: "user" }, + ); + + await program.parseAsync( + ["gateway", "agent", "--message", "hello", "--deliver"], + { from: "user" }, + ); + + expect(randomIdempotencyKey).toHaveBeenCalled(); + const callArgs = callGateway.mock.calls.map((c) => c[0]) as Array<{ + method: string; + params?: { idempotencyKey?: string }; + expectFinal?: boolean; + }>; + expect(callArgs.some((c) => c.method === "send")).toBe(true); + expect( + callArgs.some((c) => c.method === "agent" && c.expectFinal === true), + ).toBe(true); + expect(callArgs.every((c) => c.params?.idempotencyKey === "rk_test")).toBe( + true, + ); + }); + + it("validates gateway ports and handles force/start errors", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + + const { registerGatewayCli } = await import("./gateway-cli.js"); + + // Invalid port + const programInvalidPort = new Command(); + programInvalidPort.exitOverride(); + registerGatewayCli(programInvalidPort); + await expect( + programInvalidPort.parseAsync(["gateway", "--port", "0"], { + from: "user", + }), + ).rejects.toThrow("__exit__:1"); + + // Force free failure + forceFreePort.mockImplementationOnce(() => { + throw new Error("boom"); + }); + const programForceFail = new Command(); + programForceFail.exitOverride(); + registerGatewayCli(programForceFail); + await expect( + programForceFail.parseAsync(["gateway", "--port", "18789", "--force"], { + from: "user", + }), + ).rejects.toThrow("__exit__:1"); + + // Start failure (generic) + startGatewayServer.mockRejectedValueOnce(new Error("nope")); + const programStartFail = new Command(); + programStartFail.exitOverride(); + registerGatewayCli(programStartFail); + const beforeSigterm = new Set(process.listeners("SIGTERM")); + const beforeSigint = new Set(process.listeners("SIGINT")); + await expect( + programStartFail.parseAsync(["gateway", "--port", "18789"], { + from: "user", + }), + ).rejects.toThrow("__exit__:1"); + for (const listener of process.listeners("SIGTERM")) { + if (!beforeSigterm.has(listener)) + process.removeListener("SIGTERM", listener); + } + for (const listener of process.listeners("SIGINT")) { + if (!beforeSigint.has(listener)) + process.removeListener("SIGINT", listener); + } + }); +}); diff --git a/src/commands/health.command.coverage.test.ts b/src/commands/health.command.coverage.test.ts new file mode 100644 index 000000000..c8046edf8 --- /dev/null +++ b/src/commands/health.command.coverage.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { HealthSummary } from "./health.js"; +import { healthCommand } from "./health.js"; + +const callGatewayMock = vi.fn(); +const logWebSelfIdMock = vi.fn(); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (...args: unknown[]) => callGatewayMock(...args), +})); + +vi.mock("../web/session.js", () => ({ + webAuthExists: vi.fn(async () => true), + getWebAuthAgeMs: vi.fn(() => 0), + logWebSelfId: (...args: unknown[]) => logWebSelfIdMock(...args), +})); + +describe("healthCommand (coverage)", () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("prints the rich text summary when linked and configured", async () => { + callGatewayMock.mockResolvedValueOnce({ + ok: true, + ts: Date.now(), + durationMs: 5, + web: { + linked: true, + authAgeMs: 5 * 60_000, + connect: { ok: true, status: 200, elapsedMs: 10 }, + }, + telegram: { + configured: true, + probe: { + ok: true, + elapsedMs: 7, + bot: { username: "bot" }, + webhook: { url: "https://example.com/h" }, + }, + }, + heartbeatSeconds: 60, + sessions: { + path: "/tmp/sessions.json", + count: 2, + recent: [ + { key: "main", updatedAt: Date.now() - 60_000, age: 60_000 }, + { key: "foo", updatedAt: null, age: null }, + ], + }, + } satisfies HealthSummary); + + await healthCommand({ json: false, timeoutMs: 1000 }, runtime as never); + + expect(runtime.exit).not.toHaveBeenCalled(); + expect(runtime.log.mock.calls.map((c) => String(c[0])).join("\n")).toMatch( + /Web: linked/i, + ); + expect(logWebSelfIdMock).toHaveBeenCalled(); + }); +}); diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts new file mode 100644 index 000000000..e48e9dd05 --- /dev/null +++ b/src/commands/health.snapshot.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { HealthSummary } from "./health.js"; +import { getHealthSnapshot } from "./health.js"; + +let testConfig: Record = {}; +let testStore: Record = {}; + +vi.mock("../config/config.js", () => ({ + loadConfig: () => testConfig, +})); + +vi.mock("../config/sessions.js", () => ({ + resolveStorePath: () => "/tmp/sessions.json", + loadSessionStore: () => testStore, +})); + +vi.mock("../web/session.js", () => ({ + webAuthExists: vi.fn(async () => true), + getWebAuthAgeMs: vi.fn(() => 1234), + logWebSelfId: vi.fn(), +})); + +vi.mock("../web/reconnect.js", () => ({ + resolveHeartbeatSeconds: vi.fn(() => 60), +})); + +describe("getHealthSnapshot", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("skips telegram probe when not configured", async () => { + testConfig = { inbound: { reply: { session: { store: "/tmp/x" } } } }; + testStore = { + global: { updatedAt: Date.now() }, + unknown: { updatedAt: Date.now() }, + main: { updatedAt: 1000 }, + foo: { updatedAt: 2000 }, + }; + vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); + const snap = (await getHealthSnapshot(10)) satisfies HealthSummary; + expect(snap.ok).toBe(true); + expect(snap.telegram.configured).toBe(false); + expect(snap.telegram.probe).toBeUndefined(); + expect(snap.sessions.count).toBe(2); + expect(snap.sessions.recent[0]?.key).toBe("foo"); + }); + + it("probes telegram getMe + webhook info when configured", async () => { + testConfig = { telegram: { botToken: "t-1" } }; + testStore = {}; + + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + calls.push(url); + if (url.includes("/getMe")) { + return { + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { id: 1, username: "bot" }, + }), + } as unknown as Response; + } + if (url.includes("/getWebhookInfo")) { + return { + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { + url: "https://example.com/h", + has_custom_certificate: false, + }, + }), + } as unknown as Response; + } + return { + ok: false, + status: 404, + json: async () => ({ ok: false, description: "nope" }), + } as unknown as Response; + }), + ); + + const snap = await getHealthSnapshot(25); + expect(snap.telegram.configured).toBe(true); + expect(snap.telegram.probe?.ok).toBe(true); + expect(snap.telegram.probe?.bot?.username).toBe("bot"); + expect(snap.telegram.probe?.webhook?.url).toMatch(/^https:/); + expect(calls.some((c) => c.includes("/getMe"))).toBe(true); + expect(calls.some((c) => c.includes("/getWebhookInfo"))).toBe(true); + }); + + it("returns a structured telegram probe error when getMe fails", async () => { + testConfig = { telegram: { botToken: "bad-token" } }; + testStore = {}; + + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url.includes("/getMe")) { + return { + ok: false, + status: 401, + json: async () => ({ ok: false, description: "unauthorized" }), + } as unknown as Response; + } + throw new Error("unexpected"); + }), + ); + + const snap = await getHealthSnapshot(25); + expect(snap.telegram.configured).toBe(true); + expect(snap.telegram.probe?.ok).toBe(false); + expect(snap.telegram.probe?.status).toBe(401); + expect(snap.telegram.probe?.error).toMatch(/unauthorized/i); + }); + + it("captures unexpected probe exceptions as errors", async () => { + testConfig = { telegram: { botToken: "t-err" } }; + testStore = {}; + + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("network down"); + }), + ); + + const snap = await getHealthSnapshot(25); + expect(snap.telegram.configured).toBe(true); + expect(snap.telegram.probe?.ok).toBe(false); + expect(snap.telegram.probe?.error).toMatch(/network down/i); + }); +}); diff --git a/src/infra/clawdis-mac.test.ts b/src/infra/clawdis-mac.test.ts new file mode 100644 index 000000000..7ad852022 --- /dev/null +++ b/src/infra/clawdis-mac.test.ts @@ -0,0 +1,124 @@ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import type { RuntimeEnv } from "../runtime.js"; + +const runExecCalls = vi.hoisted( + () => [] as Array<{ cmd: string; args: string[] }>, +); +const runCommandCalls = vi.hoisted( + () => [] as Array<{ argv: string[]; timeoutMs: number }>, +); + +let runExecThrows = false; + +vi.mock("../process/exec.js", () => ({ + runExec: vi.fn(async (cmd: string, args: string[]) => { + runExecCalls.push({ cmd, args }); + if (runExecThrows) throw new Error("which failed"); + return { stdout: "/usr/local/bin/clawdis-mac\n", stderr: "" }; + }), + runCommandWithTimeout: vi.fn(async (argv: string[], timeoutMs: number) => { + runCommandCalls.push({ argv, timeoutMs }); + return { stdout: "ok", stderr: "", code: 0 }; + }), +})); + +import { resolveClawdisMacBinary, runClawdisMac } from "./clawdis-mac.js"; + +describe("clawdis-mac binary resolver", () => { + it("uses env override on macOS and errors elsewhere", async () => { + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: (code: number) => { + throw new Error(`exit ${code}`); + }, + }; + + if (process.platform === "darwin") { + vi.stubEnv("CLAWDIS_MAC_BIN", "/opt/bin/clawdis-mac"); + await expect(resolveClawdisMacBinary(runtime)).resolves.toBe( + "/opt/bin/clawdis-mac", + ); + return; + } + + await expect(resolveClawdisMacBinary(runtime)).rejects.toThrow(/exit 1/); + }); + + it("runs the helper with --json when requested", async () => { + if (process.platform !== "darwin") return; + vi.stubEnv("CLAWDIS_MAC_BIN", "/opt/bin/clawdis-mac"); + + const res = await runClawdisMac(["browser", "status"], { + json: true, + timeoutMs: 1234, + }); + + expect(res).toMatchObject({ stdout: "ok", code: 0 }); + expect(runCommandCalls.length).toBeGreaterThan(0); + expect(runCommandCalls.at(-1)?.argv).toEqual([ + "/opt/bin/clawdis-mac", + "--json", + "browser", + "status", + ]); + expect(runCommandCalls.at(-1)?.timeoutMs).toBe(1234); + }); + + it("falls back to `which clawdis-mac` when no override is set", async () => { + if (process.platform !== "darwin") return; + vi.stubEnv("CLAWDIS_MAC_BIN", ""); + + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: (code: number) => { + throw new Error(`exit ${code}`); + }, + }; + + const resolved = await resolveClawdisMacBinary(runtime); + expect(resolved).toBe("/usr/local/bin/clawdis-mac"); + expect(runExecCalls.some((c) => c.cmd === "which")).toBe(true); + }); + + it("falls back to ./bin/clawdis-mac when which fails", async () => { + if (process.platform !== "darwin") return; + + const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdis-mac-test-")); + const oldCwd = process.cwd(); + try { + const binDir = path.join(tmp, "bin"); + await fsp.mkdir(binDir, { recursive: true }); + const exePath = path.join(binDir, "clawdis-mac"); + await fsp.writeFile(exePath, "#!/bin/sh\necho ok\n", "utf-8"); + await fsp.chmod(exePath, 0o755); + + process.chdir(tmp); + vi.stubEnv("CLAWDIS_MAC_BIN", ""); + runExecThrows = true; + + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: (code: number) => { + throw new Error(`exit ${code}`); + }, + }; + + const resolved = await resolveClawdisMacBinary(runtime); + const expectedReal = await fsp.realpath(exePath); + const resolvedReal = await fsp.realpath(resolved); + expect(resolvedReal).toBe(expectedReal); + } finally { + runExecThrows = false; + process.chdir(oldCwd); + await fsp.rm(tmp, { recursive: true, force: true }); + } + }); +});