mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-14 04:08:05 +00:00
1242 lines
43 KiB
TypeScript
1242 lines
43 KiB
TypeScript
/**
|
|
* Integration-level tests for local CLI subprocess contracts.
|
|
*
|
|
* Validates the actual argv, stdin content, spawn options, and exit
|
|
* behavior for Claude and Codex providers — the layer that
|
|
* wiki-flags.test.ts mocks out. Uses a fake spawn that captures
|
|
* args and emits controlled events.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { EventEmitter } from 'events';
|
|
|
|
function makeFakeChild(opts?: {
|
|
exitCode?: number;
|
|
stdout?: string;
|
|
stderr?: string;
|
|
stdinEndBehavior?: 'normal' | 'epipe';
|
|
closeOnSpawn?: boolean;
|
|
}) {
|
|
const child = new EventEmitter() as any;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = new EventEmitter() as any;
|
|
child.pid = 12345;
|
|
child.kill = vi.fn();
|
|
|
|
let stdinContent = '';
|
|
const finish = () => {
|
|
queueMicrotask(() => {
|
|
if (opts?.stdinEndBehavior === 'epipe') {
|
|
child.stdin.emit('error', new Error('write EPIPE'));
|
|
}
|
|
if (opts?.stdout) {
|
|
child.stdout.emit('data', Buffer.from(opts.stdout));
|
|
}
|
|
if (opts?.stderr) {
|
|
child.stderr.emit('data', Buffer.from(opts.stderr));
|
|
}
|
|
child.emit('close', opts?.exitCode ?? 0);
|
|
});
|
|
};
|
|
|
|
child.stdin.end = vi.fn((data?: string) => {
|
|
if (data) stdinContent += data;
|
|
if (!opts?.closeOnSpawn) finish();
|
|
});
|
|
|
|
return { child, getStdin: () => stdinContent, complete: finish };
|
|
}
|
|
|
|
// ─── Claude CLI argv contract ─────────────────────────────────────────
|
|
|
|
describe('Claude CLI subprocess contract', () => {
|
|
let spawnSpy: ReturnType<typeof vi.fn>;
|
|
let fakeChild: ReturnType<typeof makeFakeChild>;
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
fakeChild = makeFakeChild({ stdout: 'Claude response text' });
|
|
spawnSpy = vi.fn<typeof import('node:child_process').spawn>(() => fakeChild.child);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('passes correct flags: -p --output-format text --no-session-persistence', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callClaudeLLM('user prompt', {});
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
expect(args).toContain('-p');
|
|
expect(args).toContain('--output-format');
|
|
expect(args).toContain('text');
|
|
expect(args).toContain('--no-session-persistence');
|
|
});
|
|
|
|
it('appends --model only when model is set', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callClaudeLLM('prompt', { model: 'claude-sonnet-4-20250514' });
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
expect(args).toContain('--model');
|
|
expect(args).toContain('claude-sonnet-4-20250514');
|
|
});
|
|
|
|
it('does not include --model when model is empty', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callClaudeLLM('prompt', {});
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
expect(args).not.toContain('--model');
|
|
});
|
|
|
|
it('sends full prompt (system + separator + user) via stdin', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callClaudeLLM('user prompt', {}, 'system prompt');
|
|
|
|
const stdinText = fakeChild.getStdin();
|
|
expect(stdinText).toBe('system prompt\n\n---\n\nuser prompt');
|
|
});
|
|
|
|
it('sends only user prompt when no system prompt', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callClaudeLLM('just the user prompt', {});
|
|
|
|
expect(fakeChild.getStdin()).toBe('just the user prompt');
|
|
});
|
|
|
|
it('sets CI=1 and windowsHide=true in spawn options', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callClaudeLLM('prompt', {});
|
|
|
|
const spawnOpts = spawnSpy.mock.calls[0][2];
|
|
expect(spawnOpts.env.CI).toBe('1');
|
|
expect(spawnOpts.windowsHide).toBe(true);
|
|
});
|
|
|
|
it('rejects with exit code and stderr on non-zero exit', async () => {
|
|
fakeChild = makeFakeChild({ exitCode: 1, stderr: 'auth required' });
|
|
spawnSpy = vi.fn<typeof import('node:child_process').spawn>(() => fakeChild.child);
|
|
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await expect(callClaudeLLM('prompt', {})).rejects.toThrow(
|
|
'claude CLI exited with code 1: auth required',
|
|
);
|
|
});
|
|
|
|
it('rejects with actionable error on empty stdout', async () => {
|
|
fakeChild = makeFakeChild({ stdout: '' });
|
|
spawnSpy = vi.fn<typeof import('node:child_process').spawn>(() => fakeChild.child);
|
|
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await expect(callClaudeLLM('prompt', {})).rejects.toThrow('claude CLI returned empty output');
|
|
});
|
|
});
|
|
|
|
// ─── Codex CLI argv contract ──────────────────────────────────────────
|
|
|
|
describe('Codex CLI subprocess contract', () => {
|
|
let spawnSpy: ReturnType<typeof vi.fn>;
|
|
let fakeChild: ReturnType<typeof makeFakeChild>;
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
fakeChild = makeFakeChild({ stdout: 'codex response' });
|
|
spawnSpy = vi.fn<typeof import('node:child_process').spawn>(() => fakeChild.child);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('passes correct subcommand and flags: exec --sandbox read-only -c approval_policy', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callCodexLLM('prompt', { workingDirectory: '/repo' });
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
expect(args).toContain('exec');
|
|
expect(args).toContain('--sandbox');
|
|
expect(args).toContain('read-only');
|
|
expect(args).toContain('-c');
|
|
expect(args).toContain('approval_policy="never"');
|
|
expect(args).toContain('--color');
|
|
expect(args).toContain('never');
|
|
});
|
|
|
|
it('includes --output-last-message with a temp file path', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callCodexLLM('prompt', { workingDirectory: '/repo' });
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
const outputIdx = args.indexOf('--output-last-message');
|
|
expect(outputIdx).toBeGreaterThan(-1);
|
|
const outputPath = args[outputIdx + 1];
|
|
expect(outputPath).toContain('gitnexus-wiki-codex-');
|
|
expect(outputPath).toContain('last-message.txt');
|
|
});
|
|
|
|
it('passes --cd with the working directory', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callCodexLLM('prompt', { workingDirectory: '/my/repo' });
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
const cdIdx = args.indexOf('--cd');
|
|
expect(cdIdx).toBeGreaterThan(-1);
|
|
expect(args[cdIdx + 1]).toBe('/my/repo');
|
|
});
|
|
|
|
it('ends args with - (stdin marker)', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callCodexLLM('prompt', { workingDirectory: '/repo' });
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
expect(args[args.length - 1]).toBe('-');
|
|
});
|
|
|
|
it('sends full prompt via stdin', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callCodexLLM('user msg', { workingDirectory: '/repo' }, 'sys msg');
|
|
|
|
expect(fakeChild.getStdin()).toBe('sys msg\n\n---\n\nuser msg');
|
|
});
|
|
|
|
it('appends --model only when set', async () => {
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callCodexLLM('prompt', { workingDirectory: '/repo', model: 'o3-pro' });
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
expect(args).toContain('--model');
|
|
expect(args).toContain('o3-pro');
|
|
const modelIdx = args.indexOf('--model');
|
|
const stdinIdx = args.indexOf('-');
|
|
expect(modelIdx).toBeLessThan(stdinIdx);
|
|
});
|
|
});
|
|
|
|
// ─── Timeout behavior ─────────────────────────────────────────────────
|
|
|
|
describe('local CLI timeout', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('kills child process after requestTimeoutMs and rejects with timeout error', async () => {
|
|
const child = new EventEmitter() as any;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = new EventEmitter() as any;
|
|
child.pid = 99;
|
|
child.kill = vi.fn();
|
|
child.stdin.end = vi.fn();
|
|
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
|
spawn: vi.fn(() => child),
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
const promise = callClaudeLLM('prompt', { requestTimeoutMs: 5000 });
|
|
|
|
vi.advanceTimersByTime(5000);
|
|
child.emit('close', null);
|
|
await expect(promise).rejects.toThrow('claude CLI timed out after 5s');
|
|
});
|
|
|
|
it('uses taskkill /T /F /PID on Windows for process-tree kill', async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
|
|
try {
|
|
const child = new EventEmitter() as any;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = new EventEmitter() as any;
|
|
child.pid = 42;
|
|
child.kill = vi.fn();
|
|
child.stdin.end = vi.fn();
|
|
|
|
const execFileSyncSpy = vi.fn().mockImplementation((cmd: string, args: string[]) => {
|
|
if (cmd !== 'taskkill') return 'claude 1.0.0';
|
|
return '';
|
|
});
|
|
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: execFileSyncSpy,
|
|
spawn: vi.fn(() => child),
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
const promise = callClaudeLLM('prompt', { requestTimeoutMs: 3000 });
|
|
vi.advanceTimersByTime(3000);
|
|
// Timeout fires, taskkill runs, but child hasn't emitted close yet.
|
|
// Emit close now to settle the promise.
|
|
child.emit('close', null);
|
|
await expect(promise).rejects.toThrow('claude CLI timed out after 3s');
|
|
|
|
const taskkillCalls = execFileSyncSpy.mock.calls.filter(
|
|
(c: unknown[]) => c[0] === 'taskkill',
|
|
);
|
|
expect(taskkillCalls.length).toBe(1);
|
|
expect(taskkillCalls[0][1]).toEqual(['/T', '/F', '/PID', '42']);
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it('falls back to child.kill() when taskkill fails on Windows', async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
|
|
try {
|
|
const child = new EventEmitter() as any;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = new EventEmitter() as any;
|
|
child.pid = 42;
|
|
child.kill = vi.fn();
|
|
child.stdin.end = vi.fn();
|
|
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockImplementation((cmd: string) => {
|
|
if (cmd === 'taskkill') throw new Error('taskkill: process not found');
|
|
return 'claude 1.0.0';
|
|
}),
|
|
spawn: vi.fn(() => child),
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
const promise = callClaudeLLM('prompt', { requestTimeoutMs: 2000 });
|
|
vi.advanceTimersByTime(2000);
|
|
child.emit('close', null);
|
|
await expect(promise).rejects.toThrow('claude CLI timed out after 2s');
|
|
expect(child.kill).toHaveBeenCalled();
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it('does not set a kill timer when requestTimeoutMs is undefined', async () => {
|
|
const child = new EventEmitter() as any;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = new EventEmitter() as any;
|
|
child.pid = 99;
|
|
child.kill = vi.fn();
|
|
child.stdin.end = vi.fn(() => {
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', Buffer.from('response'));
|
|
child.emit('close', 0);
|
|
});
|
|
});
|
|
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
|
spawn: vi.fn(() => child),
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
const response = await callClaudeLLM('prompt', {});
|
|
expect(response.content).toBe('response');
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ─── Codex output file fallback ───────────────────────────────────────
|
|
|
|
describe('Codex output file fallback', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('uses stdout when output file is missing', async () => {
|
|
const fakeChild = makeFakeChild({ stdout: 'stdout content' });
|
|
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
|
spawn: vi.fn<typeof import('node:child_process').spawn>(() => fakeChild.child),
|
|
}));
|
|
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
const result = await callCodexLLM('prompt', { workingDirectory: '/repo' });
|
|
expect(result.content).toBe('stdout content');
|
|
});
|
|
|
|
it('rejects when both stdout and output file are empty', async () => {
|
|
const fakeChild = makeFakeChild({ stdout: '' });
|
|
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
|
spawn: vi.fn<typeof import('node:child_process').spawn>(() => fakeChild.child),
|
|
}));
|
|
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await expect(callCodexLLM('prompt', { workingDirectory: '/repo' })).rejects.toThrow(
|
|
'codex CLI returned empty output',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── detectLocalCLI diagnostics ───────────────────────────────────────
|
|
|
|
describe('detectLocalCLI diagnostics', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('returns null and warns when CLI exists but --version fails (non-ENOENT)', async () => {
|
|
const warnSpy = vi.fn();
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: { info: vi.fn(), warn: warnSpy },
|
|
}));
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockImplementation(() => {
|
|
const err = new Error('exit code 1') as any;
|
|
err.status = 1;
|
|
throw err;
|
|
}),
|
|
spawn: vi.fn(),
|
|
}));
|
|
|
|
const { detectLocalCLI } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
const result = detectLocalCLI('claude');
|
|
expect(result).toBeNull();
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('--version failed'));
|
|
});
|
|
|
|
it('returns null silently when CLI is truly not found (ENOENT)', async () => {
|
|
const warnSpy = vi.fn();
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: { info: vi.fn(), warn: warnSpy },
|
|
}));
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockImplementation(() => {
|
|
const err = new Error('ENOENT') as any;
|
|
err.code = 'ENOENT';
|
|
throw err;
|
|
}),
|
|
spawn: vi.fn(),
|
|
}));
|
|
|
|
const { detectLocalCLI } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
const result = detectLocalCLI('claude');
|
|
expect(result).toBeNull();
|
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ─── onChunk progress callback ────────────────────────────────────────
|
|
|
|
describe('local CLI onChunk callback', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('fires onChunk with cumulative stdout byte count', async () => {
|
|
const child = new EventEmitter() as any;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = new EventEmitter() as any;
|
|
child.pid = 1;
|
|
child.stdin.end = vi.fn(() => {
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', Buffer.from('chunk1'));
|
|
child.stdout.emit('data', Buffer.from('chunk2'));
|
|
child.emit('close', 0);
|
|
});
|
|
});
|
|
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('claude 1.0.0'),
|
|
spawn: vi.fn(() => child),
|
|
}));
|
|
const { callClaudeLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
const chunks: number[] = [];
|
|
await callClaudeLLM('prompt', {}, undefined, { onChunk: (n) => chunks.push(n) });
|
|
|
|
expect(chunks).toEqual([6, 12]);
|
|
});
|
|
});
|
|
|
|
// ─── Codex CLI flag contract snapshot ─────────────────────────────────
|
|
|
|
describe('Codex CLI flag contract snapshot', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('spawn args match the exact expected contract (flag rename = test failure)', async () => {
|
|
const fakeChild = makeFakeChild({ stdout: 'codex output' });
|
|
const spawnSpy = vi.fn<typeof import('node:child_process').spawn>(() => fakeChild.child);
|
|
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callCodexLLM('prompt', { workingDirectory: '/repo', model: 'o3' });
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
|
|
// The contract flags start at 'exec' — skip any platform argsPrefix
|
|
// (e.g., ['/d', '/s', '/c', 'codex'] on Windows cmd.exe fallback)
|
|
const execIdx = args.indexOf('exec');
|
|
expect(execIdx).toBeGreaterThanOrEqual(0);
|
|
const contractArgs = args.slice(execIdx);
|
|
|
|
// Strip the dynamic temp path for comparison
|
|
const outputMsgIdx = contractArgs.indexOf('--output-last-message');
|
|
const normalized = [...contractArgs];
|
|
if (outputMsgIdx !== -1) {
|
|
normalized[outputMsgIdx + 1] = '<TEMP_PATH>';
|
|
}
|
|
|
|
expect(normalized).toEqual([
|
|
'exec',
|
|
'--cd',
|
|
'/repo',
|
|
'--sandbox',
|
|
'read-only',
|
|
'-c',
|
|
'approval_policy="never"',
|
|
'--color',
|
|
'never',
|
|
'--output-last-message',
|
|
'<TEMP_PATH>',
|
|
'--model',
|
|
'o3',
|
|
'-',
|
|
]);
|
|
});
|
|
|
|
it('--model appears before - (stdin marker) and after --output-last-message', async () => {
|
|
const fakeChild = makeFakeChild({ stdout: 'codex output' });
|
|
const spawnSpy = vi.fn<typeof import('node:child_process').spawn>(() => fakeChild.child);
|
|
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('codex 0.1.0'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callCodexLLM } = await import('../../src/core/wiki/local-cli-client.js');
|
|
|
|
await callCodexLLM('prompt', { workingDirectory: '/repo', model: 'test-model' });
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
const outputIdx = args.indexOf('--output-last-message');
|
|
const modelIdx = args.indexOf('--model');
|
|
const stdinIdx = args.lastIndexOf('-');
|
|
|
|
expect(outputIdx).toBeLessThan(modelIdx);
|
|
expect(modelIdx).toBeLessThan(stdinIdx);
|
|
expect(args[args.length - 1]).toBe('-');
|
|
});
|
|
});
|
|
|
|
// ─── Grok CLI subprocess contract ─────────────────────────────────────
|
|
|
|
describe('Grok CLI subprocess contract', () => {
|
|
let spawnSpy: ReturnType<typeof vi.fn>;
|
|
let fakeChild: ReturnType<typeof makeFakeChild>;
|
|
|
|
function spawnGrokChild(onSpawn?: (...args: unknown[]) => void) {
|
|
spawnSpy = vi.fn((...args: unknown[]) => {
|
|
onSpawn?.(...args);
|
|
fakeChild.complete();
|
|
return fakeChild.child;
|
|
});
|
|
}
|
|
|
|
function grokFake(
|
|
opts: Parameters<typeof makeFakeChild>[0] = {
|
|
stdout: JSON.stringify({ text: 'wiki page', stopReason: 'end_turn' }),
|
|
},
|
|
onSpawn?: (...args: unknown[]) => void,
|
|
) {
|
|
fakeChild = makeFakeChild({ ...opts, closeOnSpawn: true });
|
|
spawnGrokChild(onSpawn);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
grokFake();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function loadGrokClient() {
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: { info: vi.fn(), warn: vi.fn() },
|
|
}));
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('grok 1.0.5'),
|
|
execSync: vi.fn().mockReturnValue('grok 1.0.5'),
|
|
spawn: spawnSpy,
|
|
}));
|
|
return import('../../src/core/wiki/grok-client.js');
|
|
}
|
|
|
|
it('detectGrokCLI returns grok when grok --version succeeds and caches the result', async () => {
|
|
const execFileSync = vi.fn().mockReturnValue('grok 1.0.5');
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: { info: vi.fn(), warn: vi.fn() },
|
|
}));
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync,
|
|
execSync: vi.fn(),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { detectGrokCLI } = await import('../../src/core/wiki/grok-client.js');
|
|
|
|
expect(detectGrokCLI()).toBe('grok');
|
|
expect(detectGrokCLI()).toBe('grok');
|
|
const versionCalls = execFileSync.mock.calls.filter(
|
|
(call: unknown[]) => Array.isArray(call[1]) && (call[1] as string[]).includes('--version'),
|
|
);
|
|
expect(versionCalls).toHaveLength(1);
|
|
});
|
|
|
|
it('detectGrokCLI returns null on ENOENT', async () => {
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: { info: vi.fn(), warn: vi.fn() },
|
|
}));
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockImplementation(() => {
|
|
const err = new Error('not found') as NodeJS.ErrnoException;
|
|
err.code = 'ENOENT';
|
|
throw err;
|
|
}),
|
|
execSync: vi.fn(),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { detectGrokCLI } = await import('../../src/core/wiki/grok-client.js');
|
|
|
|
expect(detectGrokCLI()).toBeNull();
|
|
});
|
|
|
|
it('spawns grok with prompt-file, json output, max-turns 15, a tool denylist, and a strict sandbox', async () => {
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await callGrokLLM('user prompt', {});
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
expect(args).toContain('--prompt-file');
|
|
expect(args).toContain('--output-format');
|
|
expect(args).toContain('json');
|
|
const turnsIdx = args.indexOf('--max-turns');
|
|
expect(turnsIdx).toBeGreaterThanOrEqual(0);
|
|
expect(args[turnsIdx + 1]).toBe('15');
|
|
expect(args).toContain('--no-plan');
|
|
expect(args).toContain('--no-subagents');
|
|
expect(args).toContain('--disable-web-search');
|
|
expect(args).toContain('--disallowed-tools');
|
|
const denyIdx = args.indexOf('--disallowed-tools');
|
|
expect(args[denyIdx + 1]).toBe(
|
|
'run_terminal_cmd,search_replace,web_search,web_fetch,spawn_subagent',
|
|
);
|
|
expect(args).toContain('--sandbox');
|
|
const sandboxIdx = args.indexOf('--sandbox');
|
|
expect(args[sandboxIdx + 1]).toBe('strict');
|
|
expect(args).not.toContain('--tools');
|
|
expect(args).not.toContain('--yolo');
|
|
expect(args).not.toContain('--always-approve');
|
|
expect(args.some((arg) => arg.includes('user prompt'))).toBe(false);
|
|
});
|
|
|
|
it('writes system + separator + user prompt to --prompt-file, not stdin', async () => {
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await callGrokLLM('user prompt', {}, 'system prompt');
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
const fileIdx = args.indexOf('--prompt-file');
|
|
const promptPath = args[fileIdx + 1];
|
|
// File is removed after the call; capture contents via spawn-time read.
|
|
// The implementation writes before spawn, so the spy can read it if we hook spawn.
|
|
expect(fakeChild.getStdin()).toBe('');
|
|
expect(promptPath).toContain('gitnexus-wiki-grok-');
|
|
});
|
|
|
|
it('writes the concatenated prompt before spawn', async () => {
|
|
let promptContents = '';
|
|
const fs = await import('fs');
|
|
grokFake(
|
|
{ stdout: JSON.stringify({ text: 'wiki page', stopReason: 'end_turn' }) },
|
|
(..._spawnArgs: unknown[]) => {
|
|
const args = _spawnArgs[1] as string[];
|
|
const fileIdx = args.indexOf('--prompt-file');
|
|
promptContents = fs.readFileSync(args[fileIdx + 1], 'utf-8');
|
|
},
|
|
);
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await callGrokLLM('user prompt', {}, 'system prompt');
|
|
|
|
expect(promptContents).toBe('system prompt\n\n---\n\nuser prompt');
|
|
});
|
|
|
|
it('passes --cwd to an empty temp dir, not a repo path', async () => {
|
|
grokFake(
|
|
{ stdout: JSON.stringify({ text: 'wiki page', stopReason: 'end_turn' }) },
|
|
(_cmd, args, opts) => {
|
|
const argv = args as string[];
|
|
const spawnOpts = opts as { cwd?: string };
|
|
expect(argv).toContain('--cwd');
|
|
const cwdIdx = argv.indexOf('--cwd');
|
|
expect(argv[cwdIdx + 1]).toContain('gitnexus-wiki-grok-');
|
|
expect(argv[cwdIdx + 1]).toBe(spawnOpts.cwd);
|
|
},
|
|
);
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await callGrokLLM('prompt', {});
|
|
});
|
|
|
|
it('appends --model only when model is set', async () => {
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await callGrokLLM('prompt', { model: 'grok-build' });
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
expect(args).toContain('--model');
|
|
expect(args).toContain('grok-build');
|
|
});
|
|
|
|
it('does not include --model when model is empty', async () => {
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await callGrokLLM('prompt', {});
|
|
|
|
const args = spawnSpy.mock.calls[0][1] as string[];
|
|
expect(args).not.toContain('--model');
|
|
});
|
|
|
|
it('parses JSON text field as the LLM content', async () => {
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
const result = await callGrokLLM('prompt', {});
|
|
expect(result.content).toBe('wiki page');
|
|
});
|
|
|
|
it('rejects with exit code and stderr on non-zero exit', async () => {
|
|
grokFake({ exitCode: 1, stderr: 'auth required' });
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow(
|
|
'grok CLI exited with code 1: auth required',
|
|
);
|
|
});
|
|
|
|
it('rejects when grok returns a JSON error object', async () => {
|
|
grokFake({
|
|
stdout: JSON.stringify({ type: 'error', message: 'session failed' }),
|
|
});
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow('session failed');
|
|
});
|
|
|
|
it('rejects when JSON text is empty', async () => {
|
|
grokFake({ stdout: JSON.stringify({ text: ' ' }) });
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow('grok CLI returned empty text');
|
|
});
|
|
|
|
it('rejects incomplete stopReason even when text is present', async () => {
|
|
grokFake({
|
|
stdout: JSON.stringify({ text: 'cut off mid-sent', stopReason: 'max_tokens' }),
|
|
});
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow(/stopReason=max_tokens/);
|
|
});
|
|
|
|
it('rejects PascalCase incomplete stopReason', async () => {
|
|
grokFake({
|
|
stdout: JSON.stringify({ text: 'partial', stopReason: 'MaxTokens' }),
|
|
});
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow(/stopReason=MaxTokens/);
|
|
});
|
|
|
|
it('accepts end_turn stopReason with text', async () => {
|
|
grokFake({
|
|
stdout: JSON.stringify({ text: 'wiki page', stopReason: 'end_turn' }),
|
|
});
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).resolves.toEqual({ content: 'wiki page' });
|
|
});
|
|
|
|
it('rejects omitted stopReason even when text is non-empty', async () => {
|
|
grokFake({ stdout: JSON.stringify({ text: 'wiki page' }) });
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow(
|
|
'grok CLI JSON is missing stopReason=end_turn',
|
|
);
|
|
});
|
|
|
|
it('rejects null stopReason even when text is non-empty', async () => {
|
|
grokFake({ stdout: JSON.stringify({ text: 'wiki page', stopReason: null }) });
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow(
|
|
'grok CLI JSON is missing stopReason=end_turn',
|
|
);
|
|
});
|
|
|
|
it('rejects empty stopReason even when text is non-empty', async () => {
|
|
grokFake({ stdout: JSON.stringify({ text: 'wiki page', stopReason: '' }) });
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow(
|
|
'grok CLI JSON is missing stopReason=end_turn',
|
|
);
|
|
});
|
|
|
|
it('rejects empty stdout with a dedicated message', async () => {
|
|
grokFake({ stdout: ' ' });
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow('grok CLI returned empty output');
|
|
});
|
|
|
|
it('rejects non-JSON stdout with an excerpt', async () => {
|
|
grokFake({ stdout: 'Update available\nnot-json' });
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow(/non-JSON output:.*Update available/s);
|
|
});
|
|
|
|
it('rejects JSON without a text field with an excerpt', async () => {
|
|
grokFake({ stdout: JSON.stringify({ sessionId: 'abc' }) });
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow(/no text field:.*"sessionId"/s);
|
|
});
|
|
|
|
it('removes the temp prompt file and cwd after success', async () => {
|
|
const fs = await import('fs');
|
|
let promptPath = '';
|
|
let cwdPath = '';
|
|
grokFake(
|
|
{ stdout: JSON.stringify({ text: 'wiki page', stopReason: 'end_turn' }) },
|
|
(_cmd, args) => {
|
|
const argv = args as string[];
|
|
const fileIdx = argv.indexOf('--prompt-file');
|
|
promptPath = argv[fileIdx + 1];
|
|
cwdPath = argv[argv.indexOf('--cwd') + 1];
|
|
expect(fs.existsSync(promptPath)).toBe(true);
|
|
},
|
|
);
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await callGrokLLM('prompt', {});
|
|
|
|
expect(fs.existsSync(promptPath)).toBe(false);
|
|
expect(fs.existsSync(cwdPath)).toBe(false);
|
|
});
|
|
|
|
it('removes the temp dir after failure', async () => {
|
|
const fs = await import('fs');
|
|
let cwdPath = '';
|
|
grokFake({ exitCode: 1, stderr: 'nope' }, (_cmd, args) => {
|
|
const argv = args as string[];
|
|
cwdPath = argv[argv.indexOf('--cwd') + 1];
|
|
});
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow(/exited with code 1/);
|
|
expect(fs.existsSync(cwdPath)).toBe(false);
|
|
});
|
|
|
|
it('throws when grok CLI is not on PATH', async () => {
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: { info: vi.fn(), warn: vi.fn() },
|
|
}));
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockImplementation(() => {
|
|
const err = new Error('not found') as NodeJS.ErrnoException;
|
|
err.code = 'ENOENT';
|
|
throw err;
|
|
}),
|
|
execSync: vi.fn(),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callGrokLLM } = await import('../../src/core/wiki/grok-client.js');
|
|
|
|
await expect(callGrokLLM('prompt', {})).rejects.toThrow(/Grok CLI not found/);
|
|
});
|
|
|
|
it('sets CI=1 and windowsHide=true in spawn options', async () => {
|
|
const { callGrokLLM } = await loadGrokClient();
|
|
|
|
await callGrokLLM('prompt', {});
|
|
|
|
const spawnOpts = spawnSpy.mock.calls[0][2];
|
|
expect(spawnOpts.env.CI).toBe('1');
|
|
expect(spawnOpts.windowsHide).toBe(true);
|
|
});
|
|
|
|
it('on Windows detects grok and spawns via cmd.exe /d /s /c, not a .cmd path', async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
|
|
try {
|
|
const execFileSync = vi.fn().mockImplementation((cmd: string) => {
|
|
if (cmd === 'where.exe') return 'C:\\Users\\me\\AppData\\Roaming\\npm\\grok.cmd\r\n';
|
|
return 'grok 1.0.5';
|
|
});
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: { info: vi.fn(), warn: vi.fn() },
|
|
}));
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync,
|
|
execSync: vi.fn(),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { detectGrokCLI, callGrokLLM } = await import('../../src/core/wiki/grok-client.js');
|
|
|
|
expect(detectGrokCLI()).toBe('grok');
|
|
await callGrokLLM('prompt', {});
|
|
|
|
const spawnCmd = spawnSpy.mock.calls[0][0] as string;
|
|
const spawnArgs = spawnSpy.mock.calls[0][1] as string[];
|
|
expect(spawnCmd.toLowerCase()).not.toMatch(/\.cmd$/);
|
|
expect(spawnCmd).toBe(process.env.ComSpec || 'cmd.exe');
|
|
expect(spawnArgs.slice(0, 4)).toEqual(['/d', '/s', '/c', 'grok']);
|
|
expect(spawnArgs).toContain('--prompt-file');
|
|
expect(spawnArgs).toContain('--sandbox');
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it('on Windows uses ComSpec when set instead of cmd.exe', async () => {
|
|
const originalPlatform = process.platform;
|
|
const originalComSpec = process.env.ComSpec;
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe';
|
|
|
|
try {
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: { info: vi.fn(), warn: vi.fn() },
|
|
}));
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync: vi.fn().mockReturnValue('grok 1.0.5'),
|
|
execSync: vi.fn(),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const { callGrokLLM } = await import('../../src/core/wiki/grok-client.js');
|
|
|
|
await callGrokLLM('prompt', {});
|
|
|
|
expect(spawnSpy.mock.calls[0][0]).toBe('C:\\Windows\\System32\\cmd.exe');
|
|
expect((spawnSpy.mock.calls[0][1] as string[]).slice(0, 4)).toEqual([
|
|
'/d',
|
|
'/s',
|
|
'/c',
|
|
'grok',
|
|
]);
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
if (originalComSpec === undefined) delete process.env.ComSpec;
|
|
else process.env.ComSpec = originalComSpec;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Grok CLI timeout', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function hangingChild() {
|
|
const child = new EventEmitter() as any;
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = new EventEmitter() as any;
|
|
child.pid = 99;
|
|
child.kill = vi.fn();
|
|
child.stdin.end = vi.fn();
|
|
return child;
|
|
}
|
|
|
|
async function loadGrokWithChild(child: any, execFileSyncImpl?: (...args: any[]) => unknown) {
|
|
const spawnSpy = vi.fn<typeof import('node:child_process').spawn>(() => child);
|
|
const execFileSync = vi.fn(
|
|
execFileSyncImpl ??
|
|
((cmd: string) => {
|
|
if (cmd === 'taskkill') return '';
|
|
return 'grok 1.0.5';
|
|
}),
|
|
);
|
|
vi.doMock('../../src/core/logger.js', () => ({
|
|
logger: { info: vi.fn(), warn: vi.fn() },
|
|
}));
|
|
vi.doMock('child_process', () => ({
|
|
execFileSync,
|
|
execSync: vi.fn(),
|
|
spawn: spawnSpy,
|
|
}));
|
|
const mod = await import('../../src/core/wiki/grok-client.js');
|
|
return { ...mod, spawnSpy, execFileSync };
|
|
}
|
|
|
|
async function waitForSpawn(spawnSpy: ReturnType<typeof vi.fn>) {
|
|
const deadline = Date.now() + 2000;
|
|
while (spawnSpy.mock.calls.length === 0 && Date.now() < deadline) {
|
|
await Promise.resolve();
|
|
await new Promise((r) => setImmediate(r));
|
|
}
|
|
expect(spawnSpy).toHaveBeenCalled();
|
|
}
|
|
|
|
it('kills child process after requestTimeoutMs and rejects with timeout error', async () => {
|
|
const child = hangingChild();
|
|
const { callGrokLLM, spawnSpy, execFileSync } = await loadGrokWithChild(child);
|
|
const promise = callGrokLLM('prompt', { requestTimeoutMs: 5000 });
|
|
await waitForSpawn(spawnSpy);
|
|
vi.advanceTimersByTime(5000);
|
|
child.emit('close', null);
|
|
await expect(promise).rejects.toThrow('grok CLI timed out after 5s');
|
|
if (process.platform === 'win32') {
|
|
const taskkillCalls = execFileSync.mock.calls.filter((c: unknown[]) => c[0] === 'taskkill');
|
|
expect(taskkillCalls.length).toBeGreaterThan(0);
|
|
} else {
|
|
expect(child.kill).toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('uses taskkill /T /F /PID on Windows for process-tree kill', async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
try {
|
|
const child = hangingChild();
|
|
child.pid = 42;
|
|
const execFileSync = vi.fn().mockImplementation((cmd: string) => {
|
|
if (cmd === 'taskkill') return '';
|
|
if (cmd === 'where.exe') return 'C:\\npm\\grok.cmd\n';
|
|
return 'grok 1.0.5';
|
|
});
|
|
const { callGrokLLM, spawnSpy } = await loadGrokWithChild(child, execFileSync);
|
|
const promise = callGrokLLM('prompt', { requestTimeoutMs: 3000 });
|
|
await waitForSpawn(spawnSpy);
|
|
vi.advanceTimersByTime(3000);
|
|
child.emit('close', null);
|
|
await expect(promise).rejects.toThrow('grok CLI timed out after 3s');
|
|
const taskkillCalls = execFileSync.mock.calls.filter((c: unknown[]) => c[0] === 'taskkill');
|
|
expect(taskkillCalls.length).toBe(1);
|
|
expect(taskkillCalls[0][1]).toEqual(['/T', '/F', '/PID', '42']);
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it('falls back to child.kill() when taskkill fails on Windows', async () => {
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
try {
|
|
const child = hangingChild();
|
|
child.pid = 42;
|
|
const execFileSync = vi.fn().mockImplementation((cmd: string) => {
|
|
if (cmd === 'taskkill') throw new Error('taskkill: process not found');
|
|
if (cmd === 'where.exe') return 'C:\\npm\\grok.cmd\n';
|
|
return 'grok 1.0.5';
|
|
});
|
|
const { callGrokLLM, spawnSpy } = await loadGrokWithChild(child, execFileSync);
|
|
const promise = callGrokLLM('prompt', { requestTimeoutMs: 2000 });
|
|
await waitForSpawn(spawnSpy);
|
|
vi.advanceTimersByTime(2000);
|
|
child.emit('close', null);
|
|
await expect(promise).rejects.toThrow('grok CLI timed out after 2s');
|
|
expect(child.kill).toHaveBeenCalled();
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
}
|
|
});
|
|
|
|
it('does not set a kill timer when requestTimeoutMs is undefined', async () => {
|
|
const child = hangingChild();
|
|
const { callGrokLLM, spawnSpy } = await loadGrokWithChild(child);
|
|
const promise = callGrokLLM('prompt', {});
|
|
await waitForSpawn(spawnSpy);
|
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ text: 'ok', stopReason: 'end_turn' })));
|
|
child.emit('close', 0);
|
|
await expect(promise).resolves.toEqual({ content: 'ok' });
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ignores grok stdin so an unused pipe cannot EPIPE', async () => {
|
|
const child = hangingChild();
|
|
const { callGrokLLM, spawnSpy } = await loadGrokWithChild(child);
|
|
const promise = callGrokLLM('prompt', {});
|
|
await waitForSpawn(spawnSpy);
|
|
const spawnOpts = spawnSpy.mock.calls[0][2] as { stdio?: unknown };
|
|
expect(spawnOpts.stdio).toEqual(['ignore', 'pipe', 'pipe']);
|
|
expect(child.stdin.end).not.toHaveBeenCalled();
|
|
child.stdout.emit('data', Buffer.from(JSON.stringify({ text: 'ok', stopReason: 'end_turn' })));
|
|
child.emit('close', 0);
|
|
await expect(promise).resolves.toEqual({ content: 'ok' });
|
|
});
|
|
|
|
it('does not remove the temp dir until the child closes after timeout', async () => {
|
|
const fs = await import('fs');
|
|
const child = hangingChild();
|
|
const { callGrokLLM, spawnSpy } = await loadGrokWithChild(child);
|
|
const promise = callGrokLLM('prompt', { requestTimeoutMs: 5000 });
|
|
await waitForSpawn(spawnSpy);
|
|
const cwd = (spawnSpy.mock.calls[0][2] as { cwd: string }).cwd;
|
|
expect(fs.existsSync(cwd)).toBe(true);
|
|
let state: 'pending' | 'ok' | 'err' = 'pending';
|
|
void promise.then(
|
|
() => {
|
|
state = 'ok';
|
|
},
|
|
() => {
|
|
state = 'err';
|
|
},
|
|
);
|
|
vi.advanceTimersByTime(5000);
|
|
for (let i = 0; i < 30; i++) {
|
|
await new Promise((r) => setImmediate(r));
|
|
}
|
|
expect(state).toBe('pending');
|
|
expect(fs.existsSync(cwd)).toBe(true);
|
|
child.emit('close', null);
|
|
await expect(promise).rejects.toThrow('grok CLI timed out after 5s');
|
|
expect(fs.existsSync(cwd)).toBe(false);
|
|
});
|
|
|
|
it('does not remove the temp dir when the hard deadline rejects without a close event', async () => {
|
|
const fs = await import('fs');
|
|
const child = hangingChild();
|
|
const { callGrokLLM, spawnSpy } = await loadGrokWithChild(child);
|
|
const promise = callGrokLLM('prompt', { requestTimeoutMs: 5000 });
|
|
await waitForSpawn(spawnSpy);
|
|
const cwd = (spawnSpy.mock.calls[0][2] as { cwd: string }).cwd;
|
|
expect(fs.existsSync(cwd)).toBe(true);
|
|
|
|
vi.advanceTimersByTime(5000 + 2000 + 2000);
|
|
await expect(promise).rejects.toThrow('grok CLI timed out after 5s');
|
|
expect(fs.existsSync(cwd)).toBe(true);
|
|
|
|
// Late close rms via fire-and-forget fs.rm. Fake setTimeout would starve
|
|
// that I/O; switch to real timers and poll until the dir is gone.
|
|
vi.useRealTimers();
|
|
child.emit('close', null);
|
|
const goneDeadline = Date.now() + 2000;
|
|
while (fs.existsSync(cwd) && Date.now() < goneDeadline) {
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
}
|
|
expect(fs.existsSync(cwd)).toBe(false);
|
|
});
|
|
});
|