import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { spawnJSONLProcess, spawnProcess, type SubprocessOptions } from '../src/subprocess'; import * as cp from 'child_process'; import { EventEmitter } from 'events'; import { Readable } from 'stream'; vi.mock('child_process'); /** * Helper to collect all items from an async generator */ async function collectAsyncGenerator(generator: AsyncGenerator): Promise { const results: T[] = []; for await (const item of generator) { results.push(item); } return results; } describe('subprocess.ts', () => { let consoleSpy: { log: ReturnType; error: ReturnType; }; beforeEach(() => { vi.clearAllMocks(); consoleSpy = { log: vi.spyOn(console, 'log').mockImplementation(() => {}), error: vi.spyOn(console, 'error').mockImplementation(() => {}), }; }); afterEach(() => { consoleSpy.log.mockRestore(); consoleSpy.error.mockRestore(); }); /** * Helper to create a mock ChildProcess with stdout/stderr streams */ function createMockProcess(config: { stdoutLines?: string[]; stderrLines?: string[]; exitCode?: number; error?: Error; delayMs?: number; }) { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; // Create readable streams for stdout and stderr const stdout = new Readable({ read() {} }); const stderr = new Readable({ read() {} }); mockProcess.stdout = stdout; mockProcess.stderr = stderr; mockProcess.kill = vi.fn().mockReturnValue(true); // Use process.nextTick to ensure readline interface is set up first process.nextTick(() => { // Emit stderr lines immediately if (config.stderrLines) { for (const line of config.stderrLines) { stderr.emit('data', Buffer.from(line)); } } // Emit stdout lines with small delays to ensure readline processes them const emitLines = async () => { if (config.stdoutLines) { for (const line of config.stdoutLines) { stdout.push(line + '\n'); // Small delay to allow readline to process await new Promise((resolve) => setImmediate(resolve)); } } // Small delay before ending stream await new Promise((resolve) => setImmediate(resolve)); stdout.push(null); // End stdout // Small delay before exit await new Promise((resolve) => setTimeout(resolve, config.delayMs ?? 10)); // Emit exit or error if (config.error) { mockProcess.emit('error', config.error); } else { mockProcess.emit('exit', config.exitCode ?? 0); } }; emitLines(); }); return mockProcess; } describe('spawnJSONLProcess', () => { const baseOptions: SubprocessOptions = { command: 'test-command', args: ['arg1', 'arg2'], cwd: '/test/dir', }; it('should yield parsed JSONL objects line by line', async () => { const mockProcess = createMockProcess({ stdoutLines: [ '{"type":"start","id":1}', '{"type":"progress","value":50}', '{"type":"complete","result":"success"}', ], exitCode: 0, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(3); expect(results[0]).toEqual({ type: 'start', id: 1 }); expect(results[1]).toEqual({ type: 'progress', value: 50 }); expect(results[2]).toEqual({ type: 'complete', result: 'success' }); }); it('should skip empty lines', async () => { const mockProcess = createMockProcess({ stdoutLines: ['{"type":"first"}', '', ' ', '{"type":"second"}'], exitCode: 0, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(2); expect(results[0]).toEqual({ type: 'first' }); expect(results[1]).toEqual({ type: 'second' }); }); it('should yield error for malformed JSON and continue processing', async () => { const mockProcess = createMockProcess({ stdoutLines: ['{"type":"valid"}', '{invalid json}', '{"type":"also_valid"}'], exitCode: 0, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(3); expect(results[0]).toEqual({ type: 'valid' }); expect(results[1]).toMatchObject({ type: 'error', error: expect.stringContaining('Failed to parse output'), }); expect(results[2]).toEqual({ type: 'also_valid' }); }); it('should collect stderr output', async () => { const mockProcess = createMockProcess({ stdoutLines: ['{"type":"test"}'], stderrLines: ['Warning: something happened', 'Error: critical issue'], exitCode: 0, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); await collectAsyncGenerator(generator); expect(consoleSpy.error).toHaveBeenCalledWith( expect.stringContaining('Warning: something happened') ); expect(consoleSpy.error).toHaveBeenCalledWith( expect.stringContaining('Error: critical issue') ); }); it('should yield error on non-zero exit code', async () => { const mockProcess = createMockProcess({ stdoutLines: ['{"type":"started"}'], stderrLines: ['Process failed with error'], exitCode: 1, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(2); expect(results[0]).toEqual({ type: 'started' }); expect(results[1]).toMatchObject({ type: 'error', error: expect.stringContaining('Process failed with error'), }); }); it('should yield error with exit code when stderr is empty', async () => { const mockProcess = createMockProcess({ stdoutLines: ['{"type":"test"}'], exitCode: 127, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(2); expect(results[1]).toMatchObject({ type: 'error', error: 'Process exited with code 127', }); }); it('should handle process spawn errors', async () => { const mockProcess = createMockProcess({ error: new Error('Command not found'), }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); // When process.on('error') fires, exitCode is null // The generator should handle this gracefully expect(results).toEqual([]); }); it('should kill process on AbortController signal', async () => { const abortController = new AbortController(); const mockProcess = createMockProcess({ stdoutLines: ['{"type":"start"}'], exitCode: 0, delayMs: 100, // Delay to allow abort }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess({ ...baseOptions, abortController, }); // Start consuming the generator const promise = collectAsyncGenerator(generator); // Abort after a short delay setTimeout(() => abortController.abort(), 20); await promise; expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM'); expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('Abort signal received')); }); it('should spawn process with correct arguments', async () => { const mockProcess = createMockProcess({ exitCode: 0 }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const options: SubprocessOptions = { command: 'my-command', args: ['--flag', 'value'], cwd: '/work/dir', env: { CUSTOM_VAR: 'test' }, }; const generator = spawnJSONLProcess(options); await collectAsyncGenerator(generator); expect(cp.spawn).toHaveBeenCalledWith('my-command', ['--flag', 'value'], { cwd: '/work/dir', env: expect.objectContaining({ CUSTOM_VAR: 'test' }), stdio: ['ignore', 'pipe', 'pipe'], }); }); it('should merge env with process.env', async () => { const mockProcess = createMockProcess({ exitCode: 0 }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const options: SubprocessOptions = { command: 'test', args: [], cwd: '/test', env: { CUSTOM: 'value' }, }; const generator = spawnJSONLProcess(options); await collectAsyncGenerator(generator); expect(cp.spawn).toHaveBeenCalledWith( 'test', [], expect.objectContaining({ env: expect.objectContaining({ CUSTOM: 'value', // Should also include existing process.env NODE_ENV: process.env.NODE_ENV, }), }) ); }); it('should handle complex JSON objects', async () => { const complexObject = { type: 'complex', nested: { deep: { value: [1, 2, 3] } }, array: [{ id: 1 }, { id: 2 }], string: 'with "quotes" and \\backslashes', }; const mockProcess = createMockProcess({ stdoutLines: [JSON.stringify(complexObject)], exitCode: 0, }); vi.mocked(cp.spawn).mockReturnValue(mockProcess); const generator = spawnJSONLProcess(baseOptions); const results = await collectAsyncGenerator(generator); expect(results).toHaveLength(1); expect(results[0]).toEqual(complexObject); }); }); describe('spawnProcess', () => { const baseOptions: SubprocessOptions = { command: 'test-command', args: ['arg1'], cwd: '/test', }; it('should collect stdout and stderr', async () => { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; const stdout = new Readable({ read() {} }); const stderr = new Readable({ read() {} }); mockProcess.stdout = stdout; mockProcess.stderr = stderr; mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => { stdout.push('line 1\n'); stdout.push('line 2\n'); stdout.push(null); stderr.push('error 1\n'); stderr.push('error 2\n'); stderr.push(null); mockProcess.emit('exit', 0); }, 10); const result = await spawnProcess(baseOptions); expect(result.stdout).toBe('line 1\nline 2\n'); expect(result.stderr).toBe('error 1\nerror 2\n'); expect(result.exitCode).toBe(0); }); it('should return correct exit code', async () => { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; mockProcess.stdout = new Readable({ read() {} }); mockProcess.stderr = new Readable({ read() {} }); mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => { mockProcess.stdout.push(null); mockProcess.stderr.push(null); mockProcess.emit('exit', 42); }, 10); const result = await spawnProcess(baseOptions); expect(result.exitCode).toBe(42); }); it('should handle process errors', async () => { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; mockProcess.stdout = new Readable({ read() {} }); mockProcess.stderr = new Readable({ read() {} }); mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => { mockProcess.emit('error', new Error('Spawn failed')); }, 10); await expect(spawnProcess(baseOptions)).rejects.toThrow('Spawn failed'); }); it('should handle AbortController signal', async () => { const abortController = new AbortController(); const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; mockProcess.stdout = new Readable({ read() {} }); mockProcess.stderr = new Readable({ read() {} }); mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => abortController.abort(), 20); await expect(spawnProcess({ ...baseOptions, abortController })).rejects.toThrow( 'Process aborted' ); expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM'); }); it('should spawn with correct options', async () => { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; mockProcess.stdout = new Readable({ read() {} }); mockProcess.stderr = new Readable({ read() {} }); mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => { mockProcess.stdout.push(null); mockProcess.stderr.push(null); mockProcess.emit('exit', 0); }, 10); const options: SubprocessOptions = { command: 'my-cmd', args: ['--verbose'], cwd: '/my/dir', env: { MY_VAR: 'value' }, }; await spawnProcess(options); expect(cp.spawn).toHaveBeenCalledWith('my-cmd', ['--verbose'], { cwd: '/my/dir', env: expect.objectContaining({ MY_VAR: 'value' }), stdio: ['ignore', 'pipe', 'pipe'], }); }); it('should handle empty stdout and stderr', async () => { const mockProcess = new EventEmitter() as cp.ChildProcess & { stdout: Readable; stderr: Readable; kill: ReturnType; }; mockProcess.stdout = new Readable({ read() {} }); mockProcess.stderr = new Readable({ read() {} }); mockProcess.kill = vi.fn().mockReturnValue(true); vi.mocked(cp.spawn).mockReturnValue(mockProcess); setTimeout(() => { mockProcess.stdout.push(null); mockProcess.stderr.push(null); mockProcess.emit('exit', 0); }, 10); const result = await spawnProcess(baseOptions); expect(result.stdout).toBe(''); expect(result.stderr).toBe(''); expect(result.exitCode).toBe(0); }); }); });