98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'node:fs';
|
|
import {
|
|
findGlobalClaudeCliPath,
|
|
findClaudeInPath,
|
|
detectSourceFromPath,
|
|
findNpmGlobalCliPath,
|
|
findBunGlobalCliPath,
|
|
findHomebrewCliPath,
|
|
findNativeInstallerCliPath,
|
|
getVersion,
|
|
compareVersions,
|
|
shouldWarnAboutGoalHookJsonValidationRisk,
|
|
} from '../scripts/claude_version_utils.cjs';
|
|
|
|
describe('Claude Version Utils - Cross-Platform Detection', () => {
|
|
|
|
describe('detectSourceFromPath', () => {
|
|
|
|
describe('npm installations', () => {
|
|
it('should detect npm global installation on macOS/Linux', () => {
|
|
const result = detectSourceFromPath('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js');
|
|
expect(result).toBe('npm');
|
|
});
|
|
|
|
it('should detect npm global installation on Windows with forward slashes', () => {
|
|
const result = detectSourceFromPath('C:/Users/test/AppData/Roaming/npm/node_modules/@anthropic-ai/claude-code/cli.js');
|
|
expect(result).toBe('npm');
|
|
});
|
|
|
|
it('should detect npm global installation on Windows with backslashes', () => {
|
|
const result = detectSourceFromPath('C:\\Users\\test\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\cli.js');
|
|
expect(result).toBe('npm');
|
|
});
|
|
|
|
it('should detect npm with different scoped packages', () => {
|
|
const result = detectSourceFromPath('C:/Users/test/AppData/Roaming/npm/node_modules/@babel/core/cli.js');
|
|
expect(result).toBe('npm');
|
|
});
|
|
|
|
it('should detect npm through Homebrew', () => {
|
|
const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/cli.js');
|
|
expect(result).toBe('npm');
|
|
});
|
|
|
|
it('should NOT detect Homebrew cask as npm', () => {
|
|
const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/.claude-code-2DTsDk1V/cli.js');
|
|
expect(result).toBe('Homebrew');
|
|
});
|
|
});
|
|
|
|
describe('Bun installations', () => {
|
|
it('should detect Bun global installation on Unix', () => {
|
|
const result = detectSourceFromPath('/Users/test/.bun/bin/claude');
|
|
expect(result).toBe('Bun');
|
|
});
|
|
|
|
it('should detect Bun global installation on Windows', () => {
|
|
const result = detectSourceFromPath('C:/Users/test/.bun/bin/claude');
|
|
expect(result).toBe('Bun');
|
|
});
|
|
|
|
it('should detect Bun with @ symbol in username', () => {
|
|
const result = detectSourceFromPath('C:/Users/@specialuser/.bun/bin/claude');
|
|
expect(result).toBe('Bun');
|
|
});
|
|
|
|
it('should detect Bun in node_modules context', () => {
|
|
const result = detectSourceFromPath('/Users/test/.bun/install/global/node_modules/@anthropic-ai/claude-code/cli.js');
|
|
expect(result).toBe('Bun');
|
|
});
|
|
});
|
|
|
|
describe('Homebrew installations', () => {
|
|
it('should detect Homebrew on Apple Silicon macOS', () => {
|
|
const result = detectSourceFromPath('/opt/homebrew/bin/claude');
|
|
expect(result).toBe('Homebrew');
|
|
});
|
|
|
|
it('should detect Homebrew on Intel macOS', () => {
|
|
// Mock macOS platform
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
|
|
const result = detectSourceFromPath('/usr/local/bin/claude');
|
|
expect(result).toBe('Homebrew');
|
|
|
|
// Restore original platform
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
|
});
|
|
|
|
it('should detect native installer on Linux for /usr/local/bin/claude', () => {
|
|
// Mock Linux platform
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
|
|
const result = detectSourceFromPath('/usr/local/bin/claude');
|
|
expect(result).toBe('native installer');
|
|
|
|
// Restore original platform
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
|
});
|
|
|
|
it('should detect Homebrew on Linux', () => {
|
|
const result = detectSourceFromPath('/home/linuxbrew/.linuxbrew/bin/claude');
|
|
expect(result).toBe('Homebrew');
|
|
});
|
|
|
|
it('should detect Homebrew user installation', () => {
|
|
const result = detectSourceFromPath('/Users/test/.linuxbrew/bin/claude');
|
|
expect(result).toBe('Homebrew');
|
|
});
|
|
|
|
it('should detect Homebrew cask with hashed directory', () => {
|
|
const result = detectSourceFromPath('/opt/homebrew/lib/node_modules/@anthropic-ai/.claude-code-2DTsDk1V/cli.js');
|
|
expect(result).toBe('Homebrew');
|
|
});
|
|
|
|
it('should detect Homebrew Cellar installation', () => {
|
|
const result = detectSourceFromPath('/opt/homebrew/Cellar/claude-code/1.0.0/bin/claude');
|
|
expect(result).toBe('Homebrew');
|
|
});
|
|
});
|
|
|
|
describe('Native installer installations', () => {
|
|
it('should detect native installer on Unix ~/.local', () => {
|
|
const result = detectSourceFromPath('/Users/test/.local/bin/claude');
|
|
expect(result).toBe('native installer');
|
|
});
|
|
|
|
it('should detect native installer with versioned structure', () => {
|
|
const result = detectSourceFromPath('/Users/test/.local/share/claude/versions/2.0.69/claude');
|
|
expect(result).toBe('native installer');
|
|
});
|
|
|
|
it('should detect native installer on Windows Program Files', () => {
|
|
const result = detectSourceFromPath('C:/Program Files/Claude/claude.exe');
|
|
expect(result).toBe('native installer');
|
|
});
|
|
|
|
it('should detect native installer on Windows AppData', () => {
|
|
const result = detectSourceFromPath('C:/Users/test/AppData/Local/Claude/claude.exe');
|
|
expect(result).toBe('native installer');
|
|
});
|
|
|
|
it('should detect native installer on Windows custom location', () => {
|
|
const result = detectSourceFromPath('E:/Tools/Claude/claude.exe');
|
|
expect(result).toBe('native installer');
|
|
});
|
|
|
|
it('should detect native installer on Windows D: drive', () => {
|
|
const result = detectSourceFromPath('D:/Development/Claude/claude.exe');
|
|
expect(result).toBe('native installer');
|
|
});
|
|
|
|
it('should detect native installer in user profile', () => {
|
|
const result = detectSourceFromPath('C:/Users/test/.claude/claude.exe');
|
|
expect(result).toBe('native installer');
|
|
});
|
|
});
|
|
|
|
describe('Edge cases and special characters', () => {
|
|
it('should handle @ symbols in paths correctly', () => {
|
|
const result = detectSourceFromPath('/Users/@developer/test/node_modules/@anthropic-ai/claude-code/cli.js');
|
|
expect(result).toBe('npm');
|
|
});
|
|
|
|
it('should handle case sensitivity variations on Windows', () => {
|
|
const result = detectSourceFromPath('C:/USERS/TEST/APPDATA/ROAMING/NPM/NODE_MODULES/@ANTHROPIC-AI/CLAUDE-CODE/CLI.JS');
|
|
expect(result).toBe('npm');
|
|
});
|
|
|
|
it('should return PATH for unrecognized paths', () => {
|
|
const result = detectSourceFromPath('/some/random/path/claude');
|
|
expect(result).toBe('PATH');
|
|
});
|
|
|
|
it('should handle empty paths', () => {
|
|
const result = detectSourceFromPath('');
|
|
expect(result).toBe('PATH');
|
|
});
|
|
|
|
it('should handle relative paths', () => {
|
|
const result = detectSourceFromPath('./local/bin/claude');
|
|
expect(result).toBe('PATH');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Cross-platform compatibility', () => {
|
|
it('should handle both forward and backward slashes', () => {
|
|
const forward = detectSourceFromPath('C:/Users/test/AppData/Local/Claude/claude.exe');
|
|
const backward = detectSourceFromPath('C:\\Users\\test\\AppData\\Local\\Claude\\claude.exe');
|
|
|
|
expect(forward).toBe('native installer');
|
|
expect(backward).toBe('native installer');
|
|
});
|
|
|
|
it('should handle Windows drive letters', () => {
|
|
const drives = ['C:', 'D:', 'E:', 'Z:'];
|
|
drives.forEach(drive => {
|
|
const result = detectSourceFromPath(`${drive}/Program Files/Claude/claude.exe`);
|
|
expect(result).toBe('native installer');
|
|
});
|
|
});
|
|
|
|
it('should handle Unix-style absolute paths', () => {
|
|
const unixPaths = [
|
|
'/usr/local/bin/claude',
|
|
'/opt/homebrew/bin/claude',
|
|
'/home/user/.local/bin/claude'
|
|
];
|
|
|
|
unixPaths.forEach(path => {
|
|
const result = detectSourceFromPath(path);
|
|
expect(['Homebrew', 'native installer']).toContain(result);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Version comparison', () => {
|
|
it('should compare versions correctly', () => {
|
|
expect(compareVersions('2.0.69', '2.0.68')).toBe(1);
|
|
expect(compareVersions('2.0.68', '2.0.69')).toBe(-1);
|
|
expect(compareVersions('2.0.69', '2.0.69')).toBe(0);
|
|
expect(compareVersions('2.1.0', '2.0.69')).toBe(1);
|
|
expect(compareVersions('1.0.0', '2.0.0')).toBe(-1);
|
|
});
|
|
|
|
it('should handle malformed versions gracefully', () => {
|
|
expect(() => compareVersions('', '2.0.0')).not.toThrow();
|
|
expect(() => compareVersions('invalid', '2.0.0')).not.toThrow();
|
|
expect(() => compareVersions('2.0.0', '')).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('getVersion', () => {
|
|
it('falls back to --version for native binaries without adjacent package.json', () => {
|
|
const testCliPath = `/tmp/test-claude-version-${process.pid}-${Date.now()}`;
|
|
fs.writeFileSync(testCliPath, '#!/bin/sh\necho "2.1.177 (Claude Code)"\n');
|
|
fs.chmodSync(testCliPath, 0o755);
|
|
|
|
try {
|
|
expect(getVersion(testCliPath)).toBe('2.1.177');
|
|
} finally {
|
|
fs.unlinkSync(testCliPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('findClaudeInPath', () => {
|
|
it('ignores broken npm native placeholder shims on non-Windows platforms', () => {
|
|
if (process.platform === 'win32') {
|
|
return;
|
|
}
|
|
|
|
const root = `/tmp/test-claude-placeholder-${process.pid}-${Date.now()}`;
|
|
const binDir = `${root}/bin`;
|
|
const pkgBinDir = `${root}/node_modules/@anthropic-ai/claude-code/bin`;
|
|
const originalPath = process.env.PATH;
|
|
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
fs.mkdirSync(pkgBinDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
`${pkgBinDir}/claude.exe`,
|
|
'echo "Error: claude native binary not installed." >&2\nexit 1\n',
|
|
);
|
|
fs.chmodSync(`${pkgBinDir}/claude.exe`, 0o755);
|
|
fs.symlinkSync(`${pkgBinDir}/claude.exe`, `${binDir}/claude`);
|
|
|
|
try {
|
|
process.env.PATH = `${binDir}:${originalPath ?? ''}`;
|
|
expect(findClaudeInPath()).toBeNull();
|
|
} finally {
|
|
process.env.PATH = originalPath;
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('/goal hook JSON validation warning', () => {
|
|
it('warns for third-party Anthropic-compatible backends on affected Claude Code versions', () => {
|
|
expect(shouldWarnAboutGoalHookJsonValidationRisk('2.1.177', {
|
|
ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic',
|
|
})).toBe(true);
|
|
});
|
|
|
|
it('does not warn without ANTHROPIC_BASE_URL or on the fixed version floor', () => {
|
|
expect(shouldWarnAboutGoalHookJsonValidationRisk('2.1.177', {})).toBe(false);
|
|
expect(shouldWarnAboutGoalHookJsonValidationRisk('2.1.179', {
|
|
ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic',
|
|
})).toBe(false);
|
|
});
|
|
|
|
it('does not warn for the default Anthropic API host', () => {
|
|
expect(shouldWarnAboutGoalHookJsonValidationRisk('2.1.177', {
|
|
ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
|
|
})).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Integration scenarios', () => {
|
|
it('should handle multiple installations scenario', () => {
|
|
const scenarios = [
|
|
{ path: '/Users/test/.bun/bin/claude', expected: 'Bun' },
|
|
{ path: '/opt/homebrew/bin/claude', expected: 'Homebrew' },
|
|
{ path: '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' },
|
|
{ path: 'C:/Program Files/Claude/claude.exe', expected: 'native installer' }
|
|
];
|
|
|
|
scenarios.forEach(({ path, expected }) => {
|
|
const result = detectSourceFromPath(path);
|
|
expect(result).toBe(expected);
|
|
});
|
|
});
|
|
|
|
it('should maintain 100% success rate on all standard installation patterns', () => {
|
|
const standardPatterns = [
|
|
// npm (most common)
|
|
{ path: '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' },
|
|
{ path: 'C:/Users/test/AppData/Roaming/npm/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' },
|
|
|
|
// bun (second most common)
|
|
{ path: '/Users/test/.bun/bin/claude', expected: 'Bun' },
|
|
{ path: 'C:/Users/test/.bun/bin/claude', expected: 'Bun' },
|
|
|
|
// homebrew (macOS and Linux)
|
|
{ path: '/opt/homebrew/bin/claude', expected: 'Homebrew' },
|
|
{ path: '/home/linuxbrew/.linuxbrew/bin/claude', expected: 'Homebrew' },
|
|
{ path: '/Users/test/.linuxbrew/bin/claude', expected: 'Homebrew' }, // LinuxBrew user installation
|
|
|
|
// native installers
|
|
{ path: 'C:/Program Files/Claude/claude.exe', expected: 'native installer' },
|
|
{ path: 'C:/Users/test/AppData/Local/Claude/claude.exe', expected: 'native installer' },
|
|
{ path: '/Users/test/.local/bin/claude', expected: 'native installer' }
|
|
];
|
|
|
|
let passed = 0;
|
|
standardPatterns.forEach(({ path, expected }) => {
|
|
const result = detectSourceFromPath(path);
|
|
if (result === expected) passed++;
|
|
});
|
|
|
|
expect(passed).toBe(standardPatterns.length);
|
|
expect(passed / standardPatterns.length).toBe(1); // 100% success rate
|
|
});
|
|
|
|
it('should handle platform-specific /usr/local/bin/claude correctly', () => {
|
|
const originalPlatform = process.platform;
|
|
|
|
// Test on macOS (should be Homebrew)
|
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
const macosResult = detectSourceFromPath('/usr/local/bin/claude');
|
|
expect(macosResult).toBe('Homebrew');
|
|
|
|
// Test on Linux (should be native installer)
|
|
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
|
const linuxResult = detectSourceFromPath('/usr/local/bin/claude');
|
|
expect(linuxResult).toBe('native installer');
|
|
|
|
// Test on Windows (should fallback to PATH)
|
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
const windowsResult = detectSourceFromPath('/usr/local/bin/claude');
|
|
expect(windowsResult).toBe('PATH');
|
|
|
|
// Restore original platform
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
|
|
});
|
|
});
|
|
|
|
describe('Real-world edge cases', () => {
|
|
it('should handle complex user scenarios', () => {
|
|
const edgeCases = [
|
|
// User with npm aliased to bun
|
|
{ path: '/Users/test/node_modules/@anthropic-ai/claude-code/cli.js', expected: 'npm' },
|
|
|
|
// Multiple package managers
|
|
{ path: '/Users/test/.bun/bin/claude', expected: 'Bun' },
|
|
{ path: '/opt/homebrew/bin/claude', expected: 'Homebrew' },
|
|
|
|
// Custom installations
|
|
{ path: '/opt/custom/claude/bin/claude', expected: 'PATH' },
|
|
{ path: '/usr/local/custom/bin/claude', expected: 'PATH' }
|
|
];
|
|
|
|
edgeCases.forEach(({ path, expected }) => {
|
|
const result = detectSourceFromPath(path);
|
|
expect(result).toBe(expected);
|
|
});
|
|
});
|
|
|
|
it('should handle path traversal and normalization', () => {
|
|
const pathNormalizationTests = [
|
|
{ input: '/opt/homebrew/bin/../lib/claude', expected: 'Homebrew' },
|
|
{ input: '/Users/test/.bun/bin/./claude', expected: 'Bun' },
|
|
{ input: 'C:/Users/test/../test/AppData/Local/Claude/claude.exe', expected: 'native installer' }
|
|
];
|
|
|
|
pathNormalizationTests.forEach(({ input, expected }) => {
|
|
const result = detectSourceFromPath(input);
|
|
expect(result).toBe(expected);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('HAPPY_CLAUDE_PATH env var', () => {
|
|
const testClaudePath = '/tmp/test-claude-path';
|
|
|
|
beforeEach(() => {
|
|
// Create mock executable
|
|
fs.writeFileSync(testClaudePath, '#!/bin/bash\necho "mock"');
|
|
fs.chmodSync(testClaudePath, 0o755);
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (fs.existsSync(testClaudePath)) fs.unlinkSync(testClaudePath);
|
|
delete process.env.HAPPY_CLAUDE_PATH;
|
|
});
|
|
|
|
it('should use HAPPY_CLAUDE_PATH when set', () => {
|
|
process.env.HAPPY_CLAUDE_PATH = testClaudePath;
|
|
const result = findGlobalClaudeCliPath();
|
|
expect(result?.source).toBe('HAPPY_CLAUDE_PATH');
|
|
// Use realpathSync to handle macOS symlink (/tmp -> /private/tmp)
|
|
expect(fs.realpathSync(result?.path ?? '')).toBe(fs.realpathSync(testClaudePath));
|
|
});
|
|
|
|
it('should fall back to auto-discovery when env var not set', () => {
|
|
const result = findGlobalClaudeCliPath();
|
|
expect(result?.source).not.toBe('HAPPY_CLAUDE_PATH');
|
|
});
|
|
|
|
it('should ignore env var if path does not exist', () => {
|
|
process.env.HAPPY_CLAUDE_PATH = '/nonexistent/path/claude';
|
|
const result = findGlobalClaudeCliPath();
|
|
expect(result?.source).not.toBe('HAPPY_CLAUDE_PATH');
|
|
});
|
|
});
|