import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // We mock child_process so execFile never actually runs vi.mock("child_process", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, execFile: vi.fn(), }; }); // Import after mock is registered so the module picks up the mock import { execFile } from "child_process"; import { resolveClaude, _resetResolveClaudeCache } from "../../src/runtime/resolve-claude"; // util.promisify wraps the callback form; we need to make execFile call its callback const mockedExecFile = execFile as unknown as ReturnType; /** Helper: make execFile invoke its last argument (callback) with success */ function mockExecFileSuccess(stdout: string): void { mockedExecFile.mockImplementation( ( _cmd: string, _args: string[], _opts: object, callback: (err: null, result: { stdout: string; stderr: string }) => void ) => { callback(null, { stdout, stderr: "" }); } ); } /** Helper: make execFile invoke its callback with an error */ function mockExecFileFailure(message: string): void { mockedExecFile.mockImplementation( ( _cmd: string, _args: string[], _opts: object, callback: (err: Error) => void ) => { callback(new Error(message)); } ); } describe("resolveClaude", () => { const originalEnv = process.env; beforeEach(() => { // Reset module-level cache before each test _resetResolveClaudeCache(); // Clone env so mutations don't bleed between tests process.env = { ...originalEnv }; vi.clearAllMocks(); }); afterEach(() => { process.env = originalEnv; }); it("(a) returns CLAUDE_PATH directly when env var is set", async () => { process.env.CLAUDE_PATH = "/usr/local/bin/claude"; const result = await resolveClaude(); expect(result).toBe("/usr/local/bin/claude"); // Should NOT have called execFile at all expect(mockedExecFile).not.toHaveBeenCalled(); }); it("(b) falls back to PATH lookup when CLAUDE_PATH is not set", async () => { delete process.env.CLAUDE_PATH; mockExecFileSuccess("/home/user/.nvm/bin/claude\n"); const result = await resolveClaude(); expect(result).toBe("/home/user/.nvm/bin/claude"); expect(mockedExecFile).toHaveBeenCalledOnce(); }); it("(b) trims whitespace and picks first line from PATH lookup output", async () => { delete process.env.CLAUDE_PATH; // `where` on Windows can return multiple matches, one per line mockExecFileSuccess( "C:\\Users\\dev\\AppData\\Roaming\\npm\\claude.cmd\r\nC:\\Program Files\\claude\\claude.cmd\r\n" ); const result = await resolveClaude(); expect(result).toBe("C:\\Users\\dev\\AppData\\Roaming\\npm\\claude.cmd"); }); it("(c) throws a clear error when neither CLAUDE_PATH nor PATH lookup finds claude", async () => { delete process.env.CLAUDE_PATH; mockExecFileFailure("not found"); await expect(resolveClaude()).rejects.toThrow( /Claude Code CLI not found/ ); await expect(resolveClaude()).rejects.toThrow( /npm install -g @anthropic-ai\/claude-code/ ); }); it("caches the result after first successful resolution", async () => { delete process.env.CLAUDE_PATH; mockExecFileSuccess("/usr/bin/claude"); const first = await resolveClaude(); const second = await resolveClaude(); expect(first).toBe("/usr/bin/claude"); expect(second).toBe("/usr/bin/claude"); // execFile should only have been called once due to caching expect(mockedExecFile).toHaveBeenCalledOnce(); }); });