f9447f8e5f
CI / typecheck · build · test · bundle-size (push) Failing after 1s
CI / clean-room dependency closure smoke (push) Failing after 1s
CI / server-runtime e2e (docker · pg + valkey) (push) Failing after 2s
Deploy Install Scripts / deploy (push) Failing after 2s
Windows / build (push) Has been cancelled
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
import {
|
|
findClaudeExecutable,
|
|
resetClaudeExecutableCache,
|
|
CAPABILITY_PROBE_ARGS,
|
|
_internals,
|
|
} from '../../src/shared/find-claude-executable.js';
|
|
import { logger } from '../../src/utils/logger.js';
|
|
|
|
/**
|
|
* All probing goes through the _internals seam, so these tests swap its
|
|
* members instead of module-mocking child_process (mock.module is
|
|
* process-global and sticky in bun — see tests/preload.ts notes).
|
|
*/
|
|
|
|
interface FakeCli {
|
|
version: string;
|
|
supportsDontAsk: boolean;
|
|
/** Fails every probe (corrupt install / desktop app) — the `broken` branch. */
|
|
broken?: boolean;
|
|
}
|
|
|
|
const ORIGINALS = { ..._internals };
|
|
|
|
/** Paths that exist in the fake filesystem and how each fake CLI behaves. */
|
|
let fakeClis: Map<string, FakeCli>;
|
|
/** Every execFileSync invocation, for probe-count assertions. */
|
|
let probeCalls: Array<{ path: string; args: string[] }>;
|
|
/** Symlink map for realpathSync; identity when absent. */
|
|
let realPaths: Map<string, string>;
|
|
/** stdout of `which -a claude`; null = which fails. */
|
|
let whichOutput: string | null;
|
|
|
|
function installFakes(options: { settingsPath?: string; platform?: NodeJS.Platform; whereOutputs?: Record<string, string> } = {}): void {
|
|
_internals.platform = () => options.platform ?? 'darwin';
|
|
_internals.homedir = () => '/home/tester';
|
|
_internals.loadSettings = () => ({ CLAUDE_CODE_PATH: options.settingsPath ?? '' }) as ReturnType<typeof ORIGINALS.loadSettings>;
|
|
_internals.existsSync = (path) => fakeClis.has(String(path));
|
|
_internals.realpathSync = ((path: string) => realPaths.get(path) ?? path) as typeof ORIGINALS.realpathSync;
|
|
|
|
_internals.execSync = ((command: string) => {
|
|
if (options.whereOutputs && command in options.whereOutputs) {
|
|
return options.whereOutputs[command];
|
|
}
|
|
if (command === 'which -a claude' && whichOutput !== null) {
|
|
return whichOutput;
|
|
}
|
|
throw new Error(`not found: ${command}`);
|
|
}) as typeof ORIGINALS.execSync;
|
|
|
|
_internals.execFileSync = ((path: string, args: string[]) => {
|
|
probeCalls.push({ path, args });
|
|
const real = realPaths.get(path) ?? path;
|
|
const cli = fakeClis.get(path) ?? fakeClis.get(real);
|
|
if (!cli) {
|
|
const error = new Error(`spawn ${path} ENOENT`) as Error & { stderr: string };
|
|
error.stderr = '';
|
|
throw error;
|
|
}
|
|
if (cli.broken) {
|
|
const error = new Error('Command failed') as Error & { stderr: string };
|
|
error.stderr = 'cannot execute binary file';
|
|
throw error;
|
|
}
|
|
if (args.includes('--permission-mode') && !cli.supportsDontAsk) {
|
|
const error = new Error('Command failed') as Error & { stderr: string };
|
|
error.stderr = "error: option '--permission-mode <mode>' argument 'dontAsk' is invalid. Allowed choices are acceptEdits, bypassPermissions, default, plan.";
|
|
throw error;
|
|
}
|
|
return `${cli.version} (Claude Code)`;
|
|
}) as typeof ORIGINALS.execFileSync;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetClaudeExecutableCache();
|
|
fakeClis = new Map();
|
|
probeCalls = [];
|
|
realPaths = new Map();
|
|
whichOutput = null;
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.assign(_internals, ORIGINALS);
|
|
resetClaudeExecutableCache();
|
|
});
|
|
|
|
describe('findClaudeExecutable candidate selection', () => {
|
|
it('prefers the newest capable CLI over a stale binary earlier in PATH', () => {
|
|
// The exact incident shape: abandoned npm-global 2.0.42 shadows the
|
|
// auto-updated 2.1.176 in PATH order.
|
|
installFakes();
|
|
whichOutput = '/opt/homebrew/bin/claude\n/home/tester/.local/bin/claude\n';
|
|
fakeClis.set('/opt/homebrew/bin/claude', { version: '2.0.42', supportsDontAsk: false });
|
|
fakeClis.set('/home/tester/.local/bin/claude', { version: '2.1.176', supportsDontAsk: true });
|
|
|
|
expect(findClaudeExecutable('SDK')).toBe('/home/tester/.local/bin/claude');
|
|
});
|
|
|
|
it('prefers the higher version when several candidates are capable', () => {
|
|
installFakes();
|
|
whichOutput = '/a/claude\n/b/claude\n';
|
|
fakeClis.set('/a/claude', { version: '2.1.100', supportsDontAsk: true });
|
|
fakeClis.set('/b/claude', { version: '2.1.176', supportsDontAsk: true });
|
|
|
|
expect(findClaudeExecutable('SDK')).toBe('/b/claude');
|
|
});
|
|
|
|
it('breaks version ties by PATH order', () => {
|
|
installFakes();
|
|
whichOutput = '/a/claude\n/b/claude\n';
|
|
fakeClis.set('/a/claude', { version: '2.1.176', supportsDontAsk: true });
|
|
fakeClis.set('/b/claude', { version: '2.1.176', supportsDontAsk: true });
|
|
|
|
expect(findClaudeExecutable('SDK')).toBe('/a/claude');
|
|
});
|
|
|
|
it('throws an actionable error naming every too-old candidate when none are capable', () => {
|
|
installFakes();
|
|
whichOutput = '/opt/homebrew/bin/claude\n';
|
|
fakeClis.set('/opt/homebrew/bin/claude', { version: '2.0.42', supportsDontAsk: false });
|
|
|
|
expect(() => findClaudeExecutable('SDK')).toThrow(/too old/);
|
|
try {
|
|
findClaudeExecutable('SDK');
|
|
} catch (error) {
|
|
const message = (error as Error).message;
|
|
expect(message).toContain('/opt/homebrew/bin/claude');
|
|
expect(message).toContain('2.0.42');
|
|
expect(message).toContain('dontAsk');
|
|
expect(message).toContain('CLAUDE_CODE_PATH');
|
|
}
|
|
});
|
|
|
|
it('falls back to known install locations when PATH has no claude', () => {
|
|
installFakes();
|
|
whichOutput = null;
|
|
fakeClis.set('/home/tester/.local/bin/claude', { version: '2.1.176', supportsDontAsk: true });
|
|
|
|
expect(findClaudeExecutable('SDK')).toBe('/home/tester/.local/bin/claude');
|
|
});
|
|
|
|
it('dedupes PATH repeats and symlink aliases to a single probe', () => {
|
|
installFakes();
|
|
whichOutput = '/a/claude\n/a/claude\n/alias/claude\n';
|
|
realPaths.set('/alias/claude', '/a/claude');
|
|
fakeClis.set('/a/claude', { version: '2.1.176', supportsDontAsk: true });
|
|
fakeClis.set('/alias/claude', { version: '2.1.176', supportsDontAsk: true });
|
|
|
|
expect(findClaudeExecutable('SDK')).toBe('/a/claude');
|
|
expect(probeCalls.filter((call) => call.args.includes('--permission-mode')).length).toBe(1);
|
|
});
|
|
|
|
it('keeps the not-found error when nothing is installed', () => {
|
|
installFakes();
|
|
expect(() => findClaudeExecutable('SDK')).toThrow(/Claude executable not found/);
|
|
});
|
|
});
|
|
|
|
describe('findClaudeExecutable broken candidates', () => {
|
|
// A "broken" install fails BOTH the capability probe and plain --version
|
|
// (corrupt binary, dangling symlink, desktop app). Distinct from
|
|
// "incompatible", which still answers --version.
|
|
const ORIGINAL_WARN = logger.warn;
|
|
let warnings: string[];
|
|
|
|
beforeEach(() => {
|
|
warnings = [];
|
|
logger.warn = ((_component: unknown, message: string) => {
|
|
warnings.push(message);
|
|
}) as typeof logger.warn;
|
|
});
|
|
|
|
afterEach(() => {
|
|
logger.warn = ORIGINAL_WARN;
|
|
});
|
|
|
|
it('skips a broken candidate with a --version-check warning and picks the capable CLI', () => {
|
|
installFakes();
|
|
whichOutput = '/broken/claude\n/good/claude\n';
|
|
fakeClis.set('/broken/claude', { version: '0.0.0', supportsDontAsk: false, broken: true });
|
|
fakeClis.set('/good/claude', { version: '2.1.176', supportsDontAsk: true });
|
|
|
|
expect(findClaudeExecutable('SDK')).toBe('/good/claude');
|
|
expect(warnings.some((m) => m.includes('/broken/claude') && m.includes('failed --version check'))).toBe(true);
|
|
});
|
|
|
|
it('warns with desktop-app guidance when the broken candidate is a desktop-app path', () => {
|
|
installFakes({
|
|
platform: 'win32',
|
|
whereOutputs: {
|
|
'where claude': 'C:\\Users\\tester\\AppData\\Local\\AnthropicClaude\\claude.exe\r\nC:\\good\\claude.exe\r\n',
|
|
},
|
|
});
|
|
fakeClis.set('C:\\Users\\tester\\AppData\\Local\\AnthropicClaude\\claude.exe', { version: '0.0.0', supportsDontAsk: false, broken: true });
|
|
fakeClis.set('C:\\good\\claude.exe', { version: '2.1.176', supportsDontAsk: true });
|
|
|
|
expect(findClaudeExecutable('SDK')).toBe('C:\\good\\claude.exe');
|
|
expect(warnings.some((m) => m.includes('desktop app') && m.includes('AnthropicClaude'))).toBe(true);
|
|
});
|
|
|
|
it('falls through to not-found when the only candidate is broken', () => {
|
|
installFakes();
|
|
whichOutput = '/broken/claude\n';
|
|
fakeClis.set('/broken/claude', { version: '0.0.0', supportsDontAsk: false, broken: true });
|
|
|
|
expect(() => findClaudeExecutable('SDK')).toThrow(/Claude executable not found/);
|
|
});
|
|
|
|
it('reports a broken configured CLAUDE_CODE_PATH with the probe failure', () => {
|
|
installFakes({ settingsPath: '/custom/claude' });
|
|
fakeClis.set('/custom/claude', { version: '0.0.0', supportsDontAsk: false, broken: true });
|
|
|
|
expect(() => findClaudeExecutable('SDK')).toThrow(/failed the --version check/);
|
|
});
|
|
|
|
it('reports a desktop-app CLAUDE_CODE_PATH with CLI install guidance', () => {
|
|
const desktopPath = 'C:\\Users\\tester\\AppData\\Local\\AnthropicClaude\\claude.exe';
|
|
installFakes({ settingsPath: desktopPath });
|
|
fakeClis.set(desktopPath, { version: '0.0.0', supportsDontAsk: false, broken: true });
|
|
|
|
expect(() => findClaudeExecutable('SDK')).toThrow(/desktop app/);
|
|
});
|
|
});
|
|
|
|
describe('findClaudeExecutable explicit CLAUDE_CODE_PATH', () => {
|
|
it('returns a capable configured path without scanning PATH', () => {
|
|
installFakes({ settingsPath: '/custom/claude' });
|
|
fakeClis.set('/custom/claude', { version: '2.1.150', supportsDontAsk: true });
|
|
|
|
expect(findClaudeExecutable('SDK')).toBe('/custom/claude');
|
|
// Discovery (`which`) must not run when the override resolves.
|
|
expect(probeCalls.every((call) => call.path === '/custom/claude')).toBe(true);
|
|
});
|
|
|
|
it('fails loud when the configured path is too old instead of dying at spawn', () => {
|
|
installFakes({ settingsPath: '/custom/claude' });
|
|
fakeClis.set('/custom/claude', { version: '2.0.42', supportsDontAsk: false });
|
|
|
|
expect(() => findClaudeExecutable('SDK')).toThrow(/too old/);
|
|
expect(() => findClaudeExecutable('SDK')).toThrow(/2\.0\.42/);
|
|
});
|
|
|
|
it('still reports a missing configured path', () => {
|
|
installFakes({ settingsPath: '/missing/claude' });
|
|
expect(() => findClaudeExecutable('SDK')).toThrow(/does not exist/);
|
|
});
|
|
});
|
|
|
|
describe('findClaudeExecutable caching', () => {
|
|
it('caches a successful resolution and skips re-probing', () => {
|
|
installFakes();
|
|
whichOutput = '/a/claude\n';
|
|
fakeClis.set('/a/claude', { version: '2.1.176', supportsDontAsk: true });
|
|
|
|
expect(findClaudeExecutable('SDK')).toBe('/a/claude');
|
|
const probesAfterFirst = probeCalls.length;
|
|
expect(findClaudeExecutable('SDK')).toBe('/a/claude');
|
|
expect(probeCalls.length).toBe(probesAfterFirst);
|
|
|
|
resetClaudeExecutableCache();
|
|
findClaudeExecutable('SDK');
|
|
expect(probeCalls.length).toBeGreaterThan(probesAfterFirst);
|
|
});
|
|
|
|
it('never caches failure — a fixed CLI is picked up on the next call', () => {
|
|
installFakes();
|
|
whichOutput = '/a/claude\n';
|
|
fakeClis.set('/a/claude', { version: '2.0.42', supportsDontAsk: false });
|
|
expect(() => findClaudeExecutable('SDK')).toThrow(/too old/);
|
|
|
|
// User updates the CLI in place; no cache reset, no worker restart.
|
|
fakeClis.set('/a/claude', { version: '2.1.176', supportsDontAsk: true });
|
|
expect(findClaudeExecutable('SDK')).toBe('/a/claude');
|
|
});
|
|
|
|
it('re-resolves when the cached binary disappears', () => {
|
|
installFakes();
|
|
whichOutput = '/a/claude\n/b/claude\n';
|
|
fakeClis.set('/a/claude', { version: '2.1.176', supportsDontAsk: true });
|
|
fakeClis.set('/b/claude', { version: '2.1.100', supportsDontAsk: true });
|
|
|
|
expect(findClaudeExecutable('SDK')).toBe('/a/claude');
|
|
fakeClis.delete('/a/claude');
|
|
whichOutput = '/b/claude\n';
|
|
expect(findClaudeExecutable('SDK')).toBe('/b/claude');
|
|
});
|
|
});
|
|
|
|
describe('findClaudeExecutable on Windows', () => {
|
|
it('probes where-discovered candidates and applies the same version preference', () => {
|
|
installFakes({
|
|
platform: 'win32',
|
|
whereOutputs: {
|
|
'where claude.cmd': 'C:\\old\\claude.cmd\r\n',
|
|
'where claude': 'C:\\new\\claude.exe\r\n',
|
|
},
|
|
});
|
|
fakeClis.set('C:\\old\\claude.cmd', { version: '2.0.42', supportsDontAsk: false });
|
|
fakeClis.set('C:\\new\\claude.exe', { version: '2.1.176', supportsDontAsk: true });
|
|
|
|
expect(findClaudeExecutable('SDK')).toBe('C:\\new\\claude.exe');
|
|
});
|
|
});
|
|
|
|
describe('capability probe contract', () => {
|
|
it('passes the exact flags hardened options force on every spawn', () => {
|
|
// buildHardenedSdkOptions sets permissionMode 'dontAsk' unconditionally;
|
|
// the probe must cover it or stale CLIs die at spawn instead of resolve.
|
|
expect([...CAPABILITY_PROBE_ARGS]).toEqual(['--permission-mode', 'dontAsk', '--version']);
|
|
});
|
|
});
|