import { describe, it, expect, vi, beforeEach } from 'vitest'; import { EventEmitter } from 'events'; import { existsSync } from 'node:fs'; import { spawn } from 'child_process'; // Mock child_process.spawn let mockChild; vi.mock('child_process', () => ({ spawn: vi.fn(() => mockChild), })); // Import after mocking const { askAI } = await import('../ask-ai.js'); beforeEach(() => { mockChild = new EventEmitter(); mockChild.stdout = new EventEmitter(); mockChild.stderr = new EventEmitter(); mockChild.stdin = { write: vi.fn(), end: vi.fn() }; }); describe('askAI', () => { it('resolves with trimmed stdout on exit code 0', async () => { const promise = askAI('/usr/bin/echo', ['--help'], 'test prompt'); mockChild.stdout.emit('data', ' response text '); mockChild.emit('close', 0); const result = await promise; expect(result).toBe('response text'); }); it('writes prompt to stdin', async () => { const promise = askAI('/usr/bin/test', [], 'my prompt'); mockChild.emit('close', 0); await promise; expect(mockChild.stdin.write).toHaveBeenCalledWith('my prompt'); expect(mockChild.stdin.end).toHaveBeenCalled(); }); it('rejects with stderr on non-zero exit code', async () => { const promise = askAI('/usr/bin/fail', [], 'prompt'); mockChild.stderr.emit('data', 'error output'); mockChild.emit('close', 1); // Prod error format surfaces binary, code, signal, and BOTH streams so the // next failure is debuggable on sight (deploy drift over the #804 base). await expect(promise).rejects.toThrow(/\/usr\/bin\/fail exited with code 1 \(signal=none\)\..*error output/); }); it('rejects with a timeout message when killed by SIGINT/SIGTERM', async () => { const promise = askAI('/usr/bin/slow', [], 'prompt'); // Node fires the kill signal then closes; the close handler reports a clear // "timed out" rejection rather than the generic exit-code-143 path. mockChild.stdout.emit('data', 'partial'); mockChild.emit('close', null, 'SIGINT'); await expect(promise).rejects.toThrow(/\/usr\/bin\/slow timed out after \d+ms/); }); it('rejects on spawn error', async () => { const promise = askAI('/nonexistent', [], 'prompt'); mockChild.emit('error', new Error('ENOENT')); await expect(promise).rejects.toThrow('ENOENT'); }); it('concatenates multiple stdout chunks', async () => { const promise = askAI('/usr/bin/test', [], 'prompt'); mockChild.stdout.emit('data', 'chunk1'); mockChild.stdout.emit('data', 'chunk2'); mockChild.emit('close', 0); const result = await promise; expect(result).toBe('chunk1chunk2'); }); it('spawns in an isolated temp dir by default (not the bot cwd)', async () => { const promise = askAI('/usr/bin/test', [], 'prompt'); mockChild.emit('close', 0); await promise; const opts = spawn.mock.calls.at(-1)[2]; expect(opts.cwd).toBeTruthy(); // Default sandbox lives under the OS temp dir, never the process cwd. expect(opts.cwd).not.toBe(process.cwd()); expect(opts.cwd).toMatch(/pr-review-/); }); it('honors an explicit cwd when provided', async () => { const promise = askAI('/usr/bin/test', [], 'prompt', { cwd: '/some/explicit/dir' }); mockChild.emit('close', 0); await promise; const opts = spawn.mock.calls.at(-1)[2]; expect(opts.cwd).toBe('/some/explicit/dir'); }); it('scrubs PWD/OLDPWD to the sandbox so env cannot point back at the bot cwd', async () => { const promise = askAI('/usr/bin/test', [], 'prompt'); mockChild.emit('close', 0); await promise; const opts = spawn.mock.calls.at(-1)[2]; // Both cwd AND the env's cwd-context vars must be the sandbox, never the // reviewer app's own dir — otherwise agentic CLIs read the wrong repo. expect(opts.env.PWD).toBe(opts.cwd); expect(opts.env.OLDPWD).toBe(opts.cwd); expect(opts.env.PWD).not.toBe(process.cwd()); expect(opts.env.PWD).not.toBe(process.env.PWD); }); it('removes the default sandbox dir after the subprocess closes', async () => { const promise = askAI('/usr/bin/test', [], 'prompt'); const sandboxDir = spawn.mock.calls.at(-1)[2].cwd; expect(existsSync(sandboxDir)).toBe(true); mockChild.emit('close', 0); await promise; expect(existsSync(sandboxDir)).toBe(false); }); it('cleanup is one-shot: error then close does not throw on the deleted dir', async () => { const promise = askAI('/usr/bin/test', [], 'prompt'); const sandboxDir = spawn.mock.calls.at(-1)[2].cwd; // Node emits 'close' after 'error'; both call cleanup(). The guard must // make the second call a no-op rather than a redundant rmSync. mockChild.emit('error', new Error('boom')); mockChild.emit('close', 1); await expect(promise).rejects.toThrow('boom'); expect(existsSync(sandboxDir)).toBe(false); }); it('rejects and cleans up when spawn throws synchronously', async () => { spawn.mockImplementationOnce(() => { throw new Error('EINVAL'); }); await expect(askAI('/usr/bin/test', [], 'prompt')).rejects.toThrow('EINVAL'); }); });