- 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
118 lines
3.6 KiB
TypeScript
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();
|
|
});
|
|
});
|