mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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>
625 lines
21 KiB
TypeScript
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('-');
|
|
});
|
|
});
|