Files
automaker/libs/platform/tests/subprocess.test.ts
SuperComboGamer 584f5a3426 Merge main into massive-terminal-upgrade
Resolves merge conflicts:
- apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger
- apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions
- apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling)
- apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes
- apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 20:27:44 -05:00

503 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>;
error: ReturnType<typeof vi.spyOn>;
};
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<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.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<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);
});
});
});