|
| 1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | + |
| 3 | +const { |
| 4 | + mockAccessSync, |
| 5 | + mockExecFileSync, |
| 6 | + mockGetAppSetting, |
| 7 | + mockGetCliPath, |
| 8 | + mockLog, |
| 9 | + mockSpawn, |
| 10 | +} = vi.hoisted(() => ({ |
| 11 | + mockAccessSync: vi.fn(), |
| 12 | + mockExecFileSync: vi.fn(), |
| 13 | + mockGetAppSetting: vi.fn<(key: string) => string>((key: string) => { |
| 14 | + if (key === "claudeBinarySource") return "auto"; |
| 15 | + if (key === "claudeCustomBinaryPath") return ""; |
| 16 | + return "Harnss"; |
| 17 | + }), |
| 18 | + mockGetCliPath: vi.fn(() => "/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js"), |
| 19 | + mockLog: vi.fn(), |
| 20 | + mockSpawn: vi.fn(), |
| 21 | +})); |
| 22 | + |
| 23 | +vi.mock("fs", () => ({ |
| 24 | + default: { |
| 25 | + accessSync: mockAccessSync, |
| 26 | + constants: { X_OK: 1 }, |
| 27 | + }, |
| 28 | +})); |
| 29 | + |
| 30 | +vi.mock("os", () => ({ |
| 31 | + default: { |
| 32 | + homedir: () => "/Users/tester", |
| 33 | + }, |
| 34 | +})); |
| 35 | + |
| 36 | +vi.mock("child_process", () => ({ |
| 37 | + execFileSync: mockExecFileSync, |
| 38 | + spawn: mockSpawn, |
| 39 | +})); |
| 40 | + |
| 41 | +vi.mock("../app-settings", () => ({ |
| 42 | + getAppSetting: mockGetAppSetting, |
| 43 | +})); |
| 44 | + |
| 45 | +vi.mock("../sdk", () => ({ |
| 46 | + getCliPath: mockGetCliPath, |
| 47 | +})); |
| 48 | + |
| 49 | +vi.mock("../logger", () => ({ |
| 50 | + log: mockLog, |
| 51 | +})); |
| 52 | + |
| 53 | +function allowExecutable(...filePaths: string[]): void { |
| 54 | + mockAccessSync.mockImplementation((candidate: string) => { |
| 55 | + if (filePaths.includes(candidate)) return; |
| 56 | + throw new Error("missing"); |
| 57 | + }); |
| 58 | +} |
| 59 | + |
| 60 | +async function loadModule() { |
| 61 | + vi.resetModules(); |
| 62 | + return import("../claude-binary"); |
| 63 | +} |
| 64 | + |
| 65 | +describe("claude binary resolution", () => { |
| 66 | + beforeEach(() => { |
| 67 | + vi.unstubAllEnvs(); |
| 68 | + mockAccessSync.mockReset(); |
| 69 | + mockExecFileSync.mockReset(); |
| 70 | + mockGetAppSetting.mockReset(); |
| 71 | + mockGetAppSetting.mockImplementation((key: string): string => { |
| 72 | + if (key === "claudeBinarySource") return "auto"; |
| 73 | + if (key === "claudeCustomBinaryPath") return ""; |
| 74 | + return "Harnss"; |
| 75 | + }); |
| 76 | + mockGetCliPath.mockReset(); |
| 77 | + mockGetCliPath.mockReturnValue("/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js"); |
| 78 | + mockLog.mockReset(); |
| 79 | + mockSpawn.mockReset(); |
| 80 | + }); |
| 81 | + |
| 82 | + it("uses a valid custom executable path", async () => { |
| 83 | + mockGetAppSetting.mockImplementation((key: string): string => { |
| 84 | + if (key === "claudeBinarySource") return "custom"; |
| 85 | + if (key === "claudeCustomBinaryPath") return "/opt/bin/claude"; |
| 86 | + return "Harnss"; |
| 87 | + }); |
| 88 | + allowExecutable("/opt/bin/claude"); |
| 89 | + |
| 90 | + const mod = await loadModule(); |
| 91 | + |
| 92 | + await expect(mod.getClaudeBinaryPath()).resolves.toBe("/opt/bin/claude"); |
| 93 | + }); |
| 94 | + |
| 95 | + it("prefers the env override in auto mode", async () => { |
| 96 | + vi.stubEnv("CLAUDE_CODE_CLI_PATH", "/env/claude"); |
| 97 | + allowExecutable("/env/claude"); |
| 98 | + |
| 99 | + const mod = await loadModule(); |
| 100 | + |
| 101 | + await expect(mod.getClaudeBinaryPath({ installIfMissing: false })).resolves.toBe("/env/claude"); |
| 102 | + }); |
| 103 | + |
| 104 | + it("finds the native shim in the user local bin directory", async () => { |
| 105 | + allowExecutable("/Users/tester/.local/bin/claude"); |
| 106 | + |
| 107 | + const mod = await loadModule(); |
| 108 | + |
| 109 | + await expect(mod.getClaudeBinaryPath({ installIfMissing: false })).resolves.toBe("/Users/tester/.local/bin/claude"); |
| 110 | + }); |
| 111 | + |
| 112 | + it("falls back to PATH lookup when the shim is missing", async () => { |
| 113 | + mockExecFileSync.mockImplementation((command: string) => { |
| 114 | + if (command === "which") return "/usr/local/bin/claude\n"; |
| 115 | + throw new Error("unexpected"); |
| 116 | + }); |
| 117 | + allowExecutable("/usr/local/bin/claude"); |
| 118 | + |
| 119 | + const mod = await loadModule(); |
| 120 | + |
| 121 | + await expect(mod.getClaudeBinaryPath({ installIfMissing: false })).resolves.toBe("/usr/local/bin/claude"); |
| 122 | + }); |
| 123 | + |
| 124 | + it("uses the sdk cli fallback in auto mode when native resolution fails", async () => { |
| 125 | + mockExecFileSync.mockImplementation(() => { |
| 126 | + throw new Error("missing"); |
| 127 | + }); |
| 128 | + allowExecutable("/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js"); |
| 129 | + |
| 130 | + const mod = await loadModule(); |
| 131 | + |
| 132 | + await expect(mod.getClaudeBinaryPath({ installIfMissing: false, allowSdkFallback: true })).resolves.toBe( |
| 133 | + "/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js", |
| 134 | + ); |
| 135 | + expect(mockLog).toHaveBeenCalledWith( |
| 136 | + "CLAUDE_BINARY_SELECTED", |
| 137 | + "strategy=sdk-fallback path=/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js", |
| 138 | + ); |
| 139 | + }); |
| 140 | + |
| 141 | + it("reports status without triggering install", async () => { |
| 142 | + allowExecutable("/Users/tester/.local/bin/claude"); |
| 143 | + const mod = await loadModule(); |
| 144 | + |
| 145 | + expect(mod.getClaudeBinaryStatus()).toEqual({ |
| 146 | + installed: true, |
| 147 | + installing: false, |
| 148 | + }); |
| 149 | + }); |
| 150 | + |
| 151 | + it("returns a version when the sdk fallback path is a script", async () => { |
| 152 | + mockExecFileSync.mockImplementation((command: string, args: string[]) => { |
| 153 | + if (command === process.execPath) { |
| 154 | + expect(args).toEqual(["/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js", "--version"]); |
| 155 | + return "2.1.70\n"; |
| 156 | + } |
| 157 | + throw new Error("unexpected"); |
| 158 | + }); |
| 159 | + allowExecutable("/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js"); |
| 160 | + |
| 161 | + const mod = await loadModule(); |
| 162 | + |
| 163 | + await expect(mod.getClaudeVersion()).resolves.toBe("2.1.70"); |
| 164 | + }); |
| 165 | +}); |
0 commit comments