mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
- Added immediate invocation of abort handler if the abort signal is already triggered, ensuring proper cleanup. - Updated test to use setImmediate for aborting, allowing the generator to start processing before the abort is called, enhancing reliability.
509 lines
15 KiB
TypeScript
509 lines
15 KiB
TypeScript
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'], {
|
|
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'], {
|
|
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);
|
|
});
|
|
});
|
|
});
|