agent-ecosystem/test/main/utils/childProcess.test.ts

572 lines
20 KiB
TypeScript

// @vitest-environment node
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import { EventEmitter } from 'events';
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
// Mock the entire child_process module so that we can inspect how our helpers
// invoke spawn/exec without hitting the real filesystem or spawning anything.
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: vi.fn(),
spawnSync: vi.fn(),
execFile: vi.fn(),
exec: vi.fn(),
};
});
// Import after the mock call so that the mocked module is returned.
import * as child from 'child_process';
import {
execCli,
killTrackedCliProcesses,
killProcessTree,
quoteWindowsCmdArg,
spawnCli,
} from '@main/utils/childProcess';
type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void;
// Helper to temporarily override process.platform
function setPlatform(value: string) {
Object.defineProperty(process, 'platform', {
value,
configurable: true,
writable: true,
});
}
// restore platform after tests
const originalPlatform = process.platform;
function createGeneratedBunLauncher(): { dir: string; launcher: string; target: string } {
const dir = mkdtempSync(path.join(tmpdir(), 'cat-cli-launcher-'));
const targetDir = path.join(dir, 'dist');
mkdirSync(targetDir, { recursive: true });
const target = path.join(targetDir, 'cli.js');
writeFileSync(target, 'console.log("ok")', 'utf8');
const launcher = path.join(dir, 'cli-dev.cmd');
writeFileSync(
launcher,
[
'@echo off',
'setlocal',
'set "SCRIPT_DIR=%~dp0"',
'set "TARGET=%SCRIPT_DIR%dist\\cli.js"',
':run_target',
'bun "%TARGET%" %*',
'exit /b %ERRORLEVEL%',
'',
].join('\r\n'),
'utf8'
);
return { dir, launcher, target };
}
function createExtensionlessNpmNodeLauncher(): {
dir: string;
launcher: string;
target: string;
} {
const dir = mkdtempSync(path.join(tmpdir(), 'cat-cli-npm-launcher-'));
const targetDir = path.join(dir, 'node_modules', 'opencode-ai', 'bin');
mkdirSync(targetDir, { recursive: true });
const target = path.join(targetDir, 'opencode');
writeFileSync(target, 'console.log("ok")', 'utf8');
const launcher = path.join(dir, 'opencode.cmd');
writeFileSync(
launcher,
[
'@ECHO off',
'GOTO start',
':find_dp0',
'SET dp0=%~dp0',
'EXIT /b',
':start',
'SETLOCAL',
'CALL :find_dp0',
'IF EXIST "%dp0%\\node.exe" (',
' SET "_prog=%dp0%\\node.exe"',
') ELSE (',
' SET "_prog=node"',
')',
'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\opencode-ai\\bin\\opencode" %*',
'',
].join('\r\n'),
'utf8'
);
return { dir, launcher, target };
}
describe('cli child process helpers', () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
setPlatform(originalPlatform);
});
describe('quoteWindowsCmdArg', () => {
it('keeps percent signs literal in cmd.exe command strings', () => {
const quoted = quoteWindowsCmdArg('C:\\Users\\Alice\\a%PATH%b.txt');
expect(quoted).toContain('"C:\\Users\\Alice\\a"^%"PATH"^%"b.txt"');
expect(quoted).not.toContain('%PATH%');
expect(quoted).not.toContain('%%PATH%%');
});
});
describe('spawnCli', () => {
it('calls spawn directly when path is ascii on windows', () => {
setPlatform('win32');
(child.spawn as unknown as Mock).mockReturnValue({} as any);
const result = spawnCli('C:\\bin\\claude.exe', ['--version'], { cwd: 'x' });
expect(child.spawn).toHaveBeenCalledWith(
'C:\\bin\\claude.exe',
['--version'],
expect.objectContaining({
cwd: 'x',
env: expect.objectContaining({ CLAUDE_HOOK_JUDGE_MODE: 'true' }),
})
);
expect(result).toEqual({} as any);
});
it('hides spawned CLI windows by default but preserves explicit opt-out', () => {
setPlatform('win32');
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockReturnValue({} as any);
spawnCli('C:\\bin\\claude.exe', ['--version']);
expect(spawnMock.mock.calls[0][2]).toMatchObject({ windowsHide: true });
spawnCli('C:\\bin\\claude.exe', ['--version'], { windowsHide: false });
expect(spawnMock.mock.calls[1][2]).toMatchObject({ windowsHide: false });
});
it('falls back to shell when spawn throws EINVAL', () => {
setPlatform('win32');
const error: any = new Error('spawn EINVAL');
error.code = 'EINVAL';
const fake = {} as any;
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockImplementationOnce(() => {
throw error;
});
spawnMock.mockImplementationOnce(() => fake);
// Use ASCII path so needsShell returns false and we go through the try/catch EINVAL path
const result = spawnCli('C:\\bin\\claude.exe', ['a', 'b'], {
env: { FOO: 'bar' },
});
expect(spawnMock).toHaveBeenCalledTimes(2);
const secondArg0 = spawnMock.mock.calls[1][0] as string;
expect(secondArg0).toMatch(/claude\.exe/);
expect(spawnMock.mock.calls[1][1]).toMatchObject({ shell: true, env: { FOO: 'bar' } });
expect(result).toBe(fake);
});
it('uses shell directly for Windows cmd launchers', () => {
setPlatform('win32');
const fake = {} as any;
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockReturnValue(fake);
const result = spawnCli('C:\\runtime\\cli-dev.cmd', ['--version']);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toContain('cli-dev.cmd');
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true });
expect(result).toBe(fake);
});
it('runs generated Bun cmd launchers directly to preserve percent args', () => {
setPlatform('win32');
const fake = {} as any;
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockReturnValue(fake);
const { dir, launcher, target } = createGeneratedBunLauncher();
try {
const result = spawnCli(launcher, ['--model', 'test%PATH%"arg']);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toBe('bun');
expect(spawnMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell');
expect(result).toBe(fake);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('runs extensionless npm node cmd launchers directly', () => {
setPlatform('win32');
const fake = {} as any;
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockReturnValue(fake);
const { dir, launcher, target } = createExtensionlessNpmNodeLauncher();
try {
const result = spawnCli(launcher, ['--model', 'test%PATH%"arg']);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toBe('node');
expect(spawnMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell');
expect(result).toBe(fake);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('uses shell directly when path contains non-ASCII on windows', () => {
setPlatform('win32');
const fake = {} as any;
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockReturnValue(fake);
const result = spawnCli('C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd', ['a', 'b'], {
env: { FOO: 'bar' },
});
// Non-ASCII detected upfront — single spawn call with shell: true
expect(spawnMock).toHaveBeenCalledTimes(1);
const shellCmd = spawnMock.mock.calls[0][0] as string;
expect(shellCmd).toMatch(/claude\.cmd/);
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true, env: { FOO: 'bar' } });
expect(result).toBe(fake);
});
it('does not use shell when not on windows', () => {
setPlatform('linux');
(child.spawn as unknown as Mock).mockReturnValue({} as any);
const result = spawnCli('/usr/bin/claude', ['--help']);
expect(child.spawn).toHaveBeenCalledWith(
'/usr/bin/claude',
['--help'],
expect.objectContaining({
env: expect.objectContaining({ CLAUDE_HOOK_JUDGE_MODE: 'true' }),
})
);
expect(result).toEqual({} as any);
});
it('kills tracked CLI processes on shutdown', () => {
setPlatform('linux');
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const fakeChild = {
pid: 123,
kill: vi.fn(),
once: vi.fn(function once() {
return fakeChild;
}),
};
(child.spawn as unknown as Mock).mockReturnValue(fakeChild);
try {
spawnCli('/usr/bin/claude', ['--version']);
killTrackedCliProcesses('SIGTERM');
expect(killSpy).toHaveBeenCalledWith(123, 'SIGTERM');
} finally {
killSpy.mockRestore();
}
});
it('untracks CLI processes after close', () => {
setPlatform('linux');
const registeredHandlers = new Map<string, () => void>();
const fakeChild = {
pid: 456,
kill: vi.fn(),
once: vi.fn(function once(event: string, handler: () => void) {
registeredHandlers.set(event, handler);
return fakeChild;
}),
};
(child.spawn as unknown as Mock).mockReturnValue(fakeChild);
spawnCli('/usr/bin/claude', ['--version']);
registeredHandlers.get('close')?.();
killTrackedCliProcesses('SIGTERM');
expect(fakeChild.kill).not.toHaveBeenCalled();
});
});
describe('execCli', () => {
it('invokes execFile when path is ASCII on windows', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
cb(null, 'ok', '');
return {} as any;
}
);
const result = await execCli('C:\\bin\\claude.exe', ['--version']);
expect(execFileMock).toHaveBeenCalledWith(
'C:\\bin\\claude.exe',
['--version'],
expect.objectContaining({
env: expect.objectContaining({ CLAUDE_HOOK_JUDGE_MODE: 'true' }),
}),
expect.any(Function)
);
expect(result.stdout).toBe('ok');
});
it('hides exec CLI windows by default but preserves explicit opt-out', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
cb(null, 'ok', '');
return {} as any;
}
);
await execCli('C:\\bin\\claude.exe', ['--version']);
expect(execFileMock.mock.calls[0][2]).toMatchObject({ windowsHide: true });
await execCli('C:\\bin\\claude.exe', ['--version'], { windowsHide: false });
expect(execFileMock.mock.calls[1][2]).toMatchObject({ windowsHide: false });
});
it('skips straight to shell for Windows cmd launchers', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;
const execMock = child.exec as unknown as Mock;
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
cb(null, '0.0.8', '');
return {} as any;
});
const result = await execCli('C:\\runtime\\cli-dev.cmd', ['--version']);
expect(execFileMock).not.toHaveBeenCalled();
expect(execMock).toHaveBeenCalled();
expect(result.stdout).toBe('0.0.8');
});
it('executes generated Bun cmd launchers directly to preserve percent args', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;
const execMock = child.exec as unknown as Mock;
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
cb(null, 'ok', '');
return {} as any;
}
);
const { dir, launcher, target } = createGeneratedBunLauncher();
try {
const result = await execCli(launcher, ['--model', 'test%PATH%"arg']);
expect(execFileMock).toHaveBeenCalledTimes(1);
expect(execFileMock.mock.calls[0][0]).toBe('bun');
expect(execFileMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
expect(execMock).not.toHaveBeenCalled();
expect(result.stdout).toBe('ok');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('executes extensionless npm node cmd launchers directly', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;
const execMock = child.exec as unknown as Mock;
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
cb(null, 'ok', '');
return {} as any;
}
);
const { dir, launcher, target } = createExtensionlessNpmNodeLauncher();
try {
const result = await execCli(launcher, ['--model', 'test%PATH%"arg']);
expect(execFileMock).toHaveBeenCalledTimes(1);
expect(execFileMock.mock.calls[0][0]).toBe('node');
expect(execFileMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
expect(execMock).not.toHaveBeenCalled();
expect(result.stdout).toBe('ok');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('skips straight to shell when path contains non-ASCII on windows', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;
const execMock = child.exec as unknown as Mock;
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
cb(null, '1.2.3', '');
return {} as any;
});
const result = await execCli('C:\\Users\\Алексей\\AppData\\Roaming\\npm\\claude.cmd', [
'--version',
]);
// non-ASCII path detected upfront — execFile should NOT be called
expect(execFileMock).not.toHaveBeenCalled();
expect(execMock).toHaveBeenCalled();
expect(result.stdout).toBe('1.2.3');
});
it('escapes percent signs and quotes for cmd.exe in shell fallback', async () => {
setPlatform('win32');
const execMock = child.exec as unknown as Mock;
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
cb(null, 'ok', '');
return {} as any;
});
await execCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--model', 'test%PATH%"arg']);
const shellCmd = execMock.mock.calls[0][0] as string;
// Keep % outside quoted chunks so cmd.exe does not expand it as an env var.
expect(shellCmd).toContain('^%"PATH"^%');
expect(shellCmd).not.toContain('%PATH%');
expect(shellCmd).not.toContain('%%PATH%%');
// Quotes inside JSON-like args must survive cmd.exe and the target argv parser.
expect(shellCmd).toContain('\\"arg');
expect(shellCmd).not.toContain('""arg');
});
it('keeps inline settings JSON as one argv-safe argument for Windows cmd launchers', async () => {
setPlatform('win32');
const execMock = child.exec as unknown as Mock;
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
cb(null, 'ok', '');
return {} as any;
});
await execCli('C:\\runtime\\cli-dev.cmd', [
'--settings',
'{"codex":{"forced_login_method":"chatgpt"}}',
'runtime',
'status',
'--json',
'--provider',
'codex',
]);
const shellCmd = execMock.mock.calls[0][0] as string;
expect(shellCmd).toContain('"{\\"codex\\":{\\"forced_login_method\\":\\"chatgpt\\"}}"');
expect(shellCmd).not.toContain('{""codex"":');
});
it('shell: true cannot be overridden by caller options', () => {
setPlatform('win32');
const spawnMock = child.spawn as unknown as Mock;
spawnMock.mockReturnValue({} as any);
spawnCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--version'], { shell: false } as any);
// shell: true must win over caller's shell: false
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true });
});
it('falls back to shell when execFile throws EINVAL on windows', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
const err = new Error('spawn EINVAL') as Error & { code?: string };
err.code = 'EINVAL';
cb(err, '', '');
return {} as any;
}
);
const execMock = child.exec as unknown as Mock;
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
cb(null, '2.3.4', '');
return {} as any;
});
// ASCII path — goes through execFile first, gets EINVAL, falls back to shell
const result = await execCli('C:\\bin\\claude.exe', ['--version']);
expect(execFileMock).toHaveBeenCalled();
expect(execMock).toHaveBeenCalled();
expect(result.stdout).toBe('2.3.4');
});
it('preserves stdout and stderr on execFile failures', async () => {
setPlatform('linux');
const execFileMock = child.execFile as unknown as Mock;
execFileMock.mockImplementation(
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
cb(new Error('Command failed'), '{"error":"bad"}', 'bun: not found');
return {} as any;
}
);
await expect(execCli('/usr/bin/claude', ['--version'])).rejects.toMatchObject({
message: 'Command failed',
stdout: '{"error":"bad"}',
stderr: 'bun: not found',
});
});
it('kills the launcher process tree on manual execFile timeout', async () => {
setPlatform('darwin');
vi.useFakeTimers();
const execFileMock = child.execFile as unknown as Mock;
const spawnSyncMock = child.spawnSync as unknown as Mock;
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
const childProcess = new EventEmitter() as EventEmitter & {
pid: number;
stdout: EventEmitter;
stderr: EventEmitter;
};
childProcess.pid = 100;
childProcess.stdout = new EventEmitter();
childProcess.stderr = new EventEmitter();
spawnSyncMock.mockReturnValue({
status: 0,
stdout: ['100 1', '101 100', '102 101', '103 100'].join('\n'),
});
execFileMock.mockImplementation(() => childProcess);
try {
const result = execCli('/tmp/cli-dev', ['runtime', 'status'], { timeout: 100 });
const expectation = expect(result).rejects.toMatchObject({
killed: true,
signal: 'SIGTERM',
stdout: 'partial stdout',
stderr: 'partial stderr',
});
childProcess.stdout.emit('data', Buffer.from('partial stdout'));
childProcess.stderr.emit('data', Buffer.from('partial stderr'));
await vi.advanceTimersByTimeAsync(100);
await expectation;
expect(execFileMock.mock.calls[0][2]).not.toHaveProperty('timeout');
expect(killSpy.mock.calls.map(([pid]) => pid)).toEqual(
expect.arrayContaining([100, 101, 102, 103])
);
} finally {
killSpy.mockRestore();
vi.useRealTimers();
}
});
});
describe('killProcessTree', () => {
it('kills POSIX descendants discovered from ps output', () => {
setPlatform('darwin');
const spawnSyncMock = child.spawnSync as unknown as Mock;
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
spawnSyncMock.mockReturnValue({
status: 0,
stdout: ['200 1', '201 200', '202 201'].join('\n'),
});
try {
killProcessTree({ pid: 200 } as any, 'SIGKILL');
expect(killSpy.mock.calls.map(([pid]) => pid)).toEqual(
expect.arrayContaining([200, 201, 202])
);
} finally {
killSpy.mockRestore();
}
});
});
});