mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
- Added deterministic API key and environment variables in e2e-tests.yml to ensure consistent test behavior. - Refactored CodexProvider tests to improve type safety and mock handling, ensuring reliable test execution. - Updated provider-factory tests to mock installation detection for CodexProvider, enhancing test isolation. - Adjusted Playwright configuration to conditionally use external backend, improving flexibility in test environments. - Enhanced kill-test-servers script to handle external server scenarios, ensuring proper cleanup of test processes. These changes improve the reliability and maintainability of the testing framework, leading to a more stable development experience.
517 lines
16 KiB
TypeScript
517 lines
16 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'],
|
|
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);
|
|
});
|
|
});
|
|
});
|