disclaw/tests/unit/resolve-claude.test.ts
Nick Tabeling bade6d07f6 fix(runner): replace shell:true with cross-spawn, add resolveClaude
- Remove shell:true from runner.ts (command injection vector closed)
- Import cross-spawn instead of child_process.spawn; add windowsHide:true
- Add src/runtime/resolve-claude.ts: CLAUDE_PATH env override, where/which
  PATH fallback, in-memory cache, clear error message on not-found
- Add tests/unit/resolve-claude.test.ts: 5 Vitest tests covering env override,
  PATH fallback, multi-line output trimming, error case, and cache behaviour
- Add cross-spawn + @types/cross-spawn to package.json; add vitest + test script
2026-04-09 10:29:58 +02:00

118 lines
3.6 KiB
TypeScript

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<typeof import("child_process")>();
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<typeof vi.fn>;
/** 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();
});
});