Skip to content
Merged
130 changes: 94 additions & 36 deletions apps/frontend/src/main/__tests__/cli-tool-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,27 @@ import {
} from '../cli-tool-manager';
import {
findWindowsExecutableViaWhere,
findWindowsExecutableViaWhereAsync
findWindowsExecutableViaWhereAsync,
isSecurePath
} from '../utils/windows-paths';
import { findExecutable, findExecutableAsync } from '../env-utils';

type SpawnOptions = Parameters<(typeof import('../env-utils'))['getSpawnOptions']>[1];
type MockDirent = import('fs').Dirent<import('node:buffer').NonSharedBuffer>;

const createDirent = (name: string, isDir: boolean): MockDirent =>
({
name,
parentPath: '',
isDirectory: () => isDir,
isFile: () => !isDir,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false
}) as unknown as MockDirent;

// Mock Electron app
vi.mock('electron', () => ({
app: {
Expand Down Expand Up @@ -52,7 +69,7 @@ vi.mock('child_process', () => {
// so when tests call vi.mocked(execFileSync).mockReturnValue(), it affects execSync too
const sharedSyncMock = vi.fn();

const mockExecFile = vi.fn((cmd: any, args: any, options: any, callback: any) => {
const mockExecFile = vi.fn((cmd: unknown, args: unknown, options: unknown, callback: unknown) => {
// Return a minimal ChildProcess-like object
const childProcess = {
stdout: { on: vi.fn() },
Expand All @@ -62,13 +79,14 @@ vi.mock('child_process', () => {

// If callback is provided, call it asynchronously
if (typeof callback === 'function') {
setImmediate(() => callback(null, 'claude-code version 1.0.0\n', ''));
const cb = callback as (error: Error | null, stdout: string, stderr: string) => void;
setImmediate(() => cb(null, 'claude-code version 1.0.0\n', ''));
}

return childProcess as any;
return childProcess as unknown as import('child_process').ChildProcess;
});

const mockExec = vi.fn((cmd: any, options: any, callback: any) => {
const mockExec = vi.fn((cmd: unknown, options: unknown, callback: unknown) => {
// Return a minimal ChildProcess-like object
const childProcess = {
stdout: { on: vi.fn() },
Expand All @@ -78,10 +96,11 @@ vi.mock('child_process', () => {

// If callback is provided, call it asynchronously
if (typeof callback === 'function') {
setImmediate(() => callback(null, 'claude-code version 1.0.0\n', ''));
const cb = callback as (error: Error | null, stdout: string, stderr: string) => void;
setImmediate(() => cb(null, 'claude-code version 1.0.0\n', ''));
}

return childProcess as any;
return childProcess as unknown as import('child_process').ChildProcess;
});

return {
Expand All @@ -93,18 +112,23 @@ vi.mock('child_process', () => {
});

// Mock env-utils to avoid PATH augmentation complexity
vi.mock('../env-utils', () => ({
vi.mock('../env-utils', () => {
const mockShouldUseShell = vi.fn((command: string) => {
if (process.platform !== 'win32') {
return false;
}
const trimmed = command.trim();
const unquoted =
trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed;
return /\.(cmd|bat)$/i.test(unquoted);
});

return ({
findExecutable: vi.fn(() => null), // Return null to force platform-specific path checking
findExecutableAsync: vi.fn(() => Promise.resolve(null)),
getAugmentedEnv: vi.fn(() => ({ PATH: '' })),
getAugmentedEnvAsync: vi.fn(() => Promise.resolve({ PATH: '' })),
shouldUseShell: vi.fn((command: string) => {
// Mock shouldUseShell to match actual behavior
if (process.platform !== 'win32') {
return false;
}
return /\.(cmd|bat)$/i.test(command);
}),
shouldUseShell: mockShouldUseShell,
getSpawnCommand: vi.fn((command: string) => {
// Mock getSpawnCommand to match actual behavior
const trimmed = command.trim();
Expand All @@ -122,12 +146,13 @@ vi.mock('../env-utils', () => ({
}
return trimmed;
}),
getSpawnOptions: vi.fn((command: string, baseOptions?: any) => ({
getSpawnOptions: vi.fn((command: string, baseOptions?: SpawnOptions) => ({
...baseOptions,
shell: /\.(cmd|bat)$/i.test(command) && process.platform === 'win32'
shell: mockShouldUseShell(command)
})),
existsAsync: vi.fn(() => Promise.resolve(false))
}));
});
});

// Mock homebrew-python utility
vi.mock('../utils/homebrew-python', () => ({
Expand All @@ -137,7 +162,11 @@ vi.mock('../utils/homebrew-python', () => ({
// Mock windows-paths utility
vi.mock('../utils/windows-paths', () => ({
findWindowsExecutableViaWhere: vi.fn(() => null),
findWindowsExecutableViaWhereAsync: vi.fn(() => Promise.resolve(null))
findWindowsExecutableViaWhereAsync: vi.fn(() => Promise.resolve(null)),
isSecurePath: vi.fn(() => true),
getWindowsExecutablePaths: vi.fn(() => []),
getWindowsExecutablePathsAsync: vi.fn(() => Promise.resolve([])),
WINDOWS_GIT_PATHS: {}
}));

describe('cli-tool-manager - Claude CLI NVM detection', () => {
Expand Down Expand Up @@ -176,12 +205,12 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
});

// Mock Node.js version directories (three versions)
const mockDirents = [
{ name: 'v20.0.0', isDirectory: () => true },
{ name: 'v22.17.0', isDirectory: () => true },
{ name: 'v18.20.0', isDirectory: () => true },
const mockDirents: MockDirent[] = [
createDirent('v20.0.0', true),
createDirent('v22.17.0', true),
createDirent('v18.20.0', true),
];
vi.mocked(readdirSync).mockReturnValue(mockDirents as any);
vi.mocked(readdirSync).mockReturnValue(mockDirents);

// Mock execFileSync to simulate successful version check
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
Expand Down Expand Up @@ -245,11 +274,11 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
return false;
});

const mockDirents = [
{ name: 'v22.17.0', isDirectory: () => true },
{ name: 'v20.0.0', isDirectory: () => true },
const mockDirents: MockDirent[] = [
createDirent('v22.17.0', true),
createDirent('v20.0.0', true),
];
vi.mocked(readdirSync).mockReturnValue(mockDirents as any);
vi.mocked(readdirSync).mockReturnValue(mockDirents);
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.5.0\n');

const result = getToolInfo('claude');
Expand All @@ -272,10 +301,10 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
return false;
});

const mockDirents = [
{ name: 'v22.17.0', isDirectory: () => true },
const mockDirents: MockDirent[] = [
createDirent('v22.17.0', true),
];
vi.mocked(readdirSync).mockReturnValue(mockDirents as any);
vi.mocked(readdirSync).mockReturnValue(mockDirents);

// Mock validation failure
vi.mocked(execFileSync).mockImplementation(() => {
Expand All @@ -301,12 +330,12 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
});

// Versions in random order
const mockDirents = [
{ name: 'v18.20.0', isDirectory: () => true },
{ name: 'v22.17.0', isDirectory: () => true },
{ name: 'v20.5.0', isDirectory: () => true },
const mockDirents: MockDirent[] = [
createDirent('v18.20.0', true),
createDirent('v22.17.0', true),
createDirent('v20.5.0', true),
];
vi.mocked(readdirSync).mockReturnValue(mockDirents as any);
vi.mocked(readdirSync).mockReturnValue(mockDirents);
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');

const result = getToolInfo('claude');
Expand Down Expand Up @@ -345,6 +374,35 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
expect(result.source).toBe('system-path');
});

it('should ignore insecure Windows Claude CLI path from where.exe', () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true
});

vi.mocked(os.homedir).mockReturnValue('C:\\Users\\test');
vi.mocked(findExecutable).mockReturnValue(null);
vi.mocked(findWindowsExecutableViaWhere).mockReturnValue(
'D:\\Tools\\claude.cmd'
);
vi.mocked(isSecurePath).mockReturnValueOnce(false);

vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('Tools') && pathStr.includes('claude.cmd')) {
return true;
}
return false;
});

const result = getToolInfo('claude');

expect(result.found).toBe(false);
expect(result.source).toBe('fallback');
expect(execFileSync).not.toHaveBeenCalled();
expect(isSecurePath).toHaveBeenCalledWith('D:\\Tools\\claude.cmd');
});

it('should detect Claude CLI in Unix .local/bin path', () => {
vi.mocked(os.homedir).mockReturnValue('/home/user');

Expand Down
8 changes: 5 additions & 3 deletions apps/frontend/src/main/__tests__/insights-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ describe('InsightsConfig', () => {
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token');
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.z.ai');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('key');
expect(env.PYTHONPATH).toBe(['/site-packages', '/backend'].join(path.delimiter));
expect(env.PYTHONPATH).toBe(
[path.resolve('/site-packages'), path.resolve('/backend')].join(path.delimiter)
);
});

it('should clear ANTHROPIC env vars in OAuth mode when no API profile is set', async () => {
Expand All @@ -84,7 +86,7 @@ describe('InsightsConfig', () => {

const env = await config.getProcessEnv();

expect(env.PYTHONPATH).toBe('/backend');
expect(env.PYTHONPATH).toBe(path.resolve('/backend'));
});

it('should keep PYTHONPATH from python env when auto-build path is missing', async () => {
Expand All @@ -94,6 +96,6 @@ describe('InsightsConfig', () => {

const env = await config.getProcessEnv();

expect(env.PYTHONPATH).toBe('/site-packages');
expect(env.PYTHONPATH).toBe(path.resolve('/site-packages'));
});
});
Loading
Loading