Spaces:
Paused
Paused
| 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<T>(generator: AsyncGenerator<T>): Promise<T[]> { | |
| const results: T[] = []; | |
| for await (const item of generator) { | |
| results.push(item); | |
| } | |
| return results; | |
| } | |
| describe('subprocess.ts', () => { | |
| let consoleSpy: { | |
| log: ReturnType<typeof vi.spyOn>; | |
| warn: ReturnType<typeof vi.spyOn>; | |
| error: ReturnType<typeof vi.spyOn>; | |
| }; | |
| beforeEach(() => { | |
| vi.clearAllMocks(); | |
| consoleSpy = { | |
| log: vi.spyOn(console, 'log').mockImplementation(() => {}), | |
| warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), | |
| error: vi.spyOn(console, 'error').mockImplementation(() => {}), | |
| }; | |
| }); | |
| afterEach(() => { | |
| consoleSpy.log.mockRestore(); | |
| consoleSpy.warn.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<typeof vi.fn>; | |
| }; | |
| // 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.warn).toHaveBeenCalledWith( | |
| expect.stringContaining('[SubprocessManager] stderr: Warning: something happened') | |
| ); | |
| expect(consoleSpy.warn).toHaveBeenCalledWith( | |
| expect.stringContaining('[SubprocessManager] stderr: 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: 200, // 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 to ensure generator has started | |
| // Use setImmediate to ensure the generator has started processing | |
| setImmediate(() => { | |
| abortController.abort(); | |
| }); | |
| 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'], | |
| expect.objectContaining({ | |
| 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<typeof vi.fn>; | |
| }; | |
| 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<typeof vi.fn>; | |
| }; | |
| 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<typeof vi.fn>; | |
| }; | |
| 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<typeof vi.fn>; | |
| }; | |
| 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<typeof vi.fn>; | |
| }; | |
| 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'], | |
| expect.objectContaining({ | |
| 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<typeof vi.fn>; | |
| }; | |
| 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); | |
| }); | |
| }); | |
| }); | |