GitNexus/gitnexus/test/unit/local-cli-subprocess.test.ts
eddie.pan 5ce448a93a
feat(wiki): support local Claude and Codex providers (#1769)
* feat(wiki): support local Claude and Codex providers

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(wiki): address local CLI provider review findings

- Add subprocess timeout: LocalCLIConfig gains requestTimeoutMs,
  runLocalCLI sets a kill timer that rejects with an actionable error
  matching the HTTP timeout message format. --timeout is no longer
  silently ignored for claude/codex providers.
- Add windowsHide: true to spawn() to prevent console window flash on
  Windows, matching cursor-client.ts behavior.
- Skip GITNEXUS_MODEL env var for local providers so a user's OpenAI
  model name doesn't cross-contaminate claude/codex CLI invocations.
  Precedence for local providers: --model → savedLocalModel → ''.
- Guard against empty stdout: reject with actionable error when CLI
  exits 0 but produces no output, preventing silent empty wiki pages.

* fix(wiki): address deep-review findings in local CLI providers

- Move empty-output guard from runLocalCLI to per-provider callers so
  Codex can read --output-last-message file even when stdout is empty
- Merge existing config in interactive setup (local + Azure paths) to
  prevent saveCLIConfig from erasing previously saved API keys
- Use StringDecoder for stdout/stderr to handle multi-byte UTF-8 chars
  split across pipe chunk boundaries
- Distinguish ENOENT from non-zero exit in detectLocalCLI so users see
  auth guidance instead of misleading "CLI not found" when the binary
  exists but is not authenticated

* test(wiki): add subprocess contract tests for local CLI providers

Add 21 integration-level tests covering the Claude and Codex subprocess
contracts that wiki-flags.test.ts mocks out:

- Claude argv: -p, --output-format text, --no-session-persistence,
  --model conditional, stdin prompt content, CI=1, windowsHide:true
- Codex argv: exec subcommand, --sandbox read-only, -c approval_policy,
  --output-last-message temp path, --cd, stdin marker, --model
- Timeout: kill timer fires and rejects, no timer when unset
- Codex file fallback: stdout used when file missing, error when both empty
- detectLocalCLI: warn on non-ENOENT, silent on ENOENT
- onChunk: cumulative byte count forwarded

Also register the test in cross-platform-tests.ts SPAWN_CLI section and
fix detectLocalCLI ENOENT detection logic (invert the check so non-ENOENT
errors produce a warning).

* fix(wiki): platform-aware process tree kill and Codex contract snapshot

- Add killChildTree helper that uses taskkill /T /F /PID on Windows to
  terminate the entire process tree (including cmd.exe grandchildren),
  with fallback to child.kill() if taskkill fails or on non-Windows
- Add Codex CLI flag contract snapshot test that locks the exact spawn
  args — any flag rename, reorder, or removal is caught immediately
- Add Windows taskkill tests: success path asserts taskkill called with
  correct PID and /T /F flags, failure path verifies child.kill() fallback

---------

Co-authored-by: eddie.pan2 <eddie.pan2@jtexpress.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Test <test@example.com>
2026-05-25 12:59:48 +01:00

625 lines
21 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';
}) {
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 = '';
child.stdin.end = vi.fn((data?: string) => {
if (data) stdinContent += data;
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);
});
});
return { child, getStdin: () => stdinContent };
}
// ─── 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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(() => 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('-');
});
});