import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import agentBrowserSetupExtension, { ensureAgentBrowser, INSTALL_COMMAND, isAgentBrowserInstalled } from "./agent-browser.ts"; type ExecResult = { code: number; stdout: string; stderr: string; killed: boolean }; function makePi(execImpl?: (command: string, args: string[], opts?: { timeout?: number }) => Promise) { const handlers = new Map(); const commands = new Map(); const exec = vi.fn( execImpl ?? (async () => ({ code: 0, stdout: "", stderr: "", killed: false }) as ExecResult), ); const pi = { exec, on: (evt: string, h: Function) => { handlers.set(evt, [...(handlers.get(evt) ?? []), h]); }, registerCommand: (name: string, options: any) => { commands.set(name, options); }, }; return { pi: pi as any, handlers, commands, exec }; } function makeCtx(overrides?: Partial<{ hasUI: boolean; confirmResult: boolean }>) { const ui = { confirm: vi.fn(async () => overrides?.confirmResult ?? true), notify: vi.fn(), }; return { hasUI: overrides?.hasUI ?? true, ui } as any; } function runSetupCommand(commands: Map, ctx: unknown): Promise { const command = commands.get("setup-agent-browser"); if (!command) throw new Error("no setup-agent-browser command registered"); return command.handler("", ctx); } const ok: ExecResult = { code: 0, stdout: "", stderr: "", killed: false }; const fail: ExecResult = { code: 1, stdout: "", stderr: "command not found", killed: false }; let tmpDir: string; beforeAll(() => { tmpDir = mkdtempSync(join(tmpdir(), "agent-browser-test-")); process.env.SELESAI_CODING_AGENT_DIR = tmpDir; process.env.PI_CODING_AGENT_DIR = tmpDir; }); afterEach(() => { vi.restoreAllMocks(); }); afterAll(() => { delete process.env.SELESAI_CODING_AGENT_DIR; delete process.env.PI_CODING_AGENT_DIR; rmSync(tmpDir, { recursive: true, force: true }); }); describe("agent-browser setup extension", () => { it("asks nothing at startup and only registers the setup-agent-browser command", async () => { const { pi, handlers, commands, exec } = makePi(); await agentBrowserSetupExtension(pi); expect(handlers.size).toBe(0); expect(commands.has("setup-agent-browser")).toBe(true); expect(exec).not.toHaveBeenCalled(); }); it("does not prompt when the CLI is already installed", async () => { const { pi, commands, exec } = makePi(); await agentBrowserSetupExtension(pi); const ctx = makeCtx(); await runSetupCommand(commands, ctx); expect(exec).toHaveBeenCalledTimes(1); const args = exec.mock.calls[0][1] as string[]; expect(args.join(" ")).toContain("--version"); expect(ctx.ui.confirm).not.toHaveBeenCalled(); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("already installed"), "info"); }); it("installs via npm when the user confirms, then provisions the browser", async () => { const execImpl = vi .fn() .mockResolvedValueOnce(fail) // probe: missing .mockResolvedValueOnce(ok) // npm i -g agent-browser .mockResolvedValueOnce(ok) // probe: now present .mockResolvedValueOnce(ok); // agent-browser install const { pi, commands, exec } = makePi(execImpl as any); await agentBrowserSetupExtension(pi); const ctx = makeCtx(); await runSetupCommand(commands, ctx); expect(ctx.ui.confirm).toHaveBeenCalledWith(expect.stringContaining("Install agent-browser?"), expect.any(String)); expect(exec).toHaveBeenCalledTimes(4); const calls = exec.mock.calls.map(([command, args]) => [command, ...(args as string[])].join(" ")); expect(calls[0]).toContain("--version"); expect(calls[1]).toContain("npm"); expect(calls[1]).toContain("agent-browser"); expect(calls[2]).toContain("--version"); expect(calls[3]).toBe(`${process.platform === "win32" ? "cmd /c " : ""}agent-browser install`); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("browser provisioned"), "info"); }); it("notifies how to install later when the user says no", async () => { const execImpl = vi.fn().mockResolvedValue(fail); const { pi, commands, exec } = makePi(execImpl as any); await agentBrowserSetupExtension(pi); const ctx = makeCtx({ confirmResult: false }); await runSetupCommand(commands, ctx); expect(exec).toHaveBeenCalledTimes(1); // probe only expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Skipped"), "info"); }); it("warns with install instructions when there is no UI", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const execImpl = vi.fn().mockResolvedValue(fail); const { pi, commands } = makePi(execImpl as any); await agentBrowserSetupExtension(pi); const ctx = makeCtx({ hasUI: false }); await runSetupCommand(commands, ctx); expect(ctx.ui.confirm).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(INSTALL_COMMAND)); }); it("reports a failed npm install and skips browser provisioning", async () => { const execImpl = vi .fn() .mockResolvedValueOnce(fail) // probe .mockResolvedValueOnce({ code: 1, stdout: "", stderr: "EACCES", killed: false }); // npm fails const { pi, commands, exec } = makePi(execImpl as any); await agentBrowserSetupExtension(pi); const ctx = makeCtx(); await runSetupCommand(commands, ctx); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("failed"), "error"); expect(exec).toHaveBeenCalledTimes(2); expect(exec.mock.calls.some((c) => (c[1] as string[]).join(" ").endsWith("agent-browser install"))).toBe(false); }); it("isAgentBrowserInstalled reports the probe result", async () => { const { pi, exec } = makePi(async () => ok); expect(await isAgentBrowserInstalled(pi)).toBe(true); expect(exec).toHaveBeenCalledTimes(1); const { pi: pi2, exec: exec2 } = makePi(async () => fail); expect(await isAgentBrowserInstalled(pi2)).toBe(false); expect(exec2).toHaveBeenCalledTimes(1); }); it("setup-agent-browser command re-runs the flow", async () => { const { pi, commands } = makePi(async () => ok); await agentBrowserSetupExtension(pi); const command = commands.get("setup-agent-browser"); expect(command).toBeDefined(); const ctx = makeCtx(); await command.handler("", ctx); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("already installed"), "info"); }); it("isAgentBrowserInstalled returns false when exec throws", async () => { const { pi } = makePi(async () => { throw new Error("spawn failed"); }); expect(await isAgentBrowserInstalled(pi)).toBe(false); }); it("isAgentBrowserInstalled returns false when the probe is killed", async () => { const { pi } = makePi(async () => ({ code: 0, stdout: "", stderr: "", killed: true })); expect(await isAgentBrowserInstalled(pi)).toBe(false); }); it("reports a timed-out npm install", async () => { const execImpl = vi .fn() .mockResolvedValueOnce(fail) // probe: missing .mockResolvedValueOnce({ code: 0, stdout: "", stderr: "", killed: true }); // npm killed const { pi, commands, exec } = makePi(execImpl as any); await agentBrowserSetupExtension(pi); const ctx = makeCtx(); await runSetupCommand(commands, ctx); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("timed out"), "error"); expect(exec).toHaveBeenCalledTimes(2); }); it("reports npm success but missing binary on PATH", async () => { const execImpl = vi .fn() .mockResolvedValueOnce(fail) // probe: missing .mockResolvedValueOnce(ok) // npm i -g agent-browser .mockResolvedValueOnce(fail) // probe: still missing .mockResolvedValueOnce(ok); // (unused) const { pi, commands, exec } = makePi(execImpl as any); await agentBrowserSetupExtension(pi); const ctx = makeCtx(); await runSetupCommand(commands, ctx); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("still not on PATH"), "warning"); expect(exec).toHaveBeenCalledTimes(3); }); it("warns when browser provisioning fails after install", async () => { const execImpl = vi .fn() .mockResolvedValueOnce(fail) // probe: missing .mockResolvedValueOnce(ok) // npm i -g agent-browser .mockResolvedValueOnce(ok) // probe: now present .mockResolvedValueOnce({ code: 1, stdout: "", stderr: "browser failed", killed: false }); // agent-browser install fails const { pi, commands, exec } = makePi(execImpl as any); await agentBrowserSetupExtension(pi); const ctx = makeCtx(); await runSetupCommand(commands, ctx); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Run `agent-browser install`"), "info"); expect(exec).toHaveBeenCalledTimes(4); }); it("provisionBrowser returns false when exec throws", async () => { const execImpl = vi .fn() .mockResolvedValueOnce(fail) // probe: missing .mockResolvedValueOnce(ok) // npm i -g agent-browser .mockResolvedValueOnce(ok) // probe: now present .mockRejectedValueOnce(new Error("spawn failed")); // agent-browser install throws const { pi, commands, exec } = makePi(execImpl as any); await agentBrowserSetupExtension(pi); const ctx = makeCtx(); await runSetupCommand(commands, ctx); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Run `agent-browser install`"), "info"); expect(exec).toHaveBeenCalledTimes(4); }); it("ensureAgentBrowser returns false when the user declines", async () => { const execImpl = vi.fn().mockResolvedValue(fail); const { pi } = makePi(execImpl as any); const ctx = makeCtx({ confirmResult: false }); expect(await ensureAgentBrowser(pi, ctx)).toBe(false); }); it("ensureAgentBrowser returns true when already installed", async () => { const { pi } = makePi(async () => ok); expect(await ensureAgentBrowser(pi, makeCtx())).toBe(true); }); it("ensureAgentBrowser returns false without UI", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const execImpl = vi.fn().mockResolvedValue(fail); const { pi } = makePi(execImpl as any); expect(await ensureAgentBrowser(pi, makeCtx({ hasUI: false }))).toBe(false); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(INSTALL_COMMAND)); }); it("ensureAgentBrowser returns false when npm install fails", async () => { const execImpl = vi .fn() .mockResolvedValueOnce(fail) // probe .mockResolvedValueOnce({ code: 1, stdout: "", stderr: "EACCES", killed: false }); // npm fails const { pi } = makePi(execImpl as any); expect(await ensureAgentBrowser(pi, makeCtx())).toBe(false); }); it("npm install failure surfaces stdout when stderr is empty", async () => { const execImpl = vi .fn() .mockResolvedValueOnce(fail) // probe .mockResolvedValueOnce({ code: 1, stdout: "npm error log", stderr: "", killed: false }); // npm fails with stdout only const { pi } = makePi(execImpl as any); const ctx = makeCtx(); expect(await ensureAgentBrowser(pi, ctx)).toBe(false); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("npm error log"), "error"); }); it("npm install failure falls back to exit status when output is empty", async () => { const execImpl = vi .fn() .mockResolvedValueOnce(fail) // probe .mockResolvedValueOnce({ code: 1, stdout: "", stderr: "", killed: false }); // npm fails with no output const { pi } = makePi(execImpl as any); const ctx = makeCtx(); expect(await ensureAgentBrowser(pi, ctx)).toBe(false); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("exit status 1"), "error"); }); it("notify is a no-op without UI even when the CLI is installed", async () => { const { pi } = makePi(async () => ok); const ctx = makeCtx({ hasUI: false }); expect(await ensureAgentBrowser(pi, ctx)).toBe(true); }); it("ensureAgentBrowser returns true after full install flow", async () => { const execImpl = vi .fn() .mockResolvedValueOnce(fail) // probe: missing .mockResolvedValueOnce(ok) // npm i -g agent-browser .mockResolvedValueOnce(ok) // probe: now present .mockResolvedValueOnce(ok); // agent-browser install const { pi } = makePi(execImpl as any); expect(await ensureAgentBrowser(pi, makeCtx())).toBe(true); }); it("cliCommand wraps args in cmd.exe on win32", () => { const original = process.platform; Object.defineProperty(process, "platform", { value: "win32" }); try { const { ensureAgentBrowser: _e, ...rest } = {} as any; // Re-import to pick up the platform at module scope is not needed; // cliCommand is module-private, so exercise it through the public flow. const execImpl = vi.fn().mockResolvedValue(ok); const { pi } = makePi(execImpl as any); return ensureAgentBrowser(pi, makeCtx()).then((result) => { expect(result).toBe(true); const call = execImpl.mock.calls[0]; expect(call[0]).toBe("cmd"); expect(call[1]).toEqual(["/c", "agent-browser", "--version"]); }); } finally { Object.defineProperty(process, "platform", { value: original }); } }); });