mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
Claude CLI 2.1.131+ rejects `--output-format stream-json` unless `--verbose` is also passed. Without it the CLI exits 1 with "When using --print, --output-format=stream-json requires --verbose", breaking the Claude Code backend. Adds the flag in src/cli.ts and locks both the flag and full args contract for Claude + Codex backends with new tests: - testClaudeCodeArgs: extended with mcp-config / disallowed-tools / chrome / slash-commands assertions and --verbose position check - testClaudeCodeStreamJsonResilience: banner-mixed NDJSON, missing result, empty stdout, multiple result events, content fallback - testClaudeCodeVerboseRegressionCanary: simulates real CLI stderr on missing --verbose and asserts error pipeline preserves exitCode + stderr - testCodexErrorPath: codex non-zero exit propagates structured error - testCodexArgsMinimalContract: deepStrictEqual lock on codex args 5-tuple - testClaudeBackendSurfacesVerboseError: smoke flow surfaces the canary - testCodexBackendPropagatesVersionFailure: version probe failure blocks smoke instead of silently continuing Also wires npm run lint:obsidian into CI alongside Biome. Closes #3 Change-Id: I6dbaa1b55172eb0b5b773bd25d1741b0603a4e4e
221 lines
7.5 KiB
JavaScript
221 lines
7.5 KiB
JavaScript
const { assert, baseSettings, EventEmitter, openAiCardsResponse, t } = require('./test-setup');
|
|
|
|
function createFakeChild(stdoutText, stderrText = '', code = 0) {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = {
|
|
written: '',
|
|
ended: false,
|
|
write(value) {
|
|
this.written += value;
|
|
},
|
|
end() {
|
|
this.ended = true;
|
|
},
|
|
};
|
|
child.pid = 1234;
|
|
child.kill = () => true;
|
|
|
|
process.nextTick(() => {
|
|
if (stdoutText) child.stdout.emit('data', Buffer.from(stdoutText));
|
|
if (stderrText) child.stderr.emit('data', Buffer.from(stderrText));
|
|
child.emit('close', code, null);
|
|
});
|
|
|
|
return child;
|
|
}
|
|
|
|
async function testClaudeBackendRunsVersionAndSmoke() {
|
|
const calls = [];
|
|
const streamJson = [
|
|
JSON.stringify({ type: 'system', subtype: 'init', tools: ['LSP'] }),
|
|
JSON.stringify({
|
|
type: 'result',
|
|
result: '{"cards":[{"title":"CLI smoke","anchor":"parallel reader smoke anchor","gist":"ok","bullets":["ok"]}]}',
|
|
}),
|
|
'',
|
|
].join('\n');
|
|
const spawnImpl = (cmd, args) => {
|
|
calls.push({ cmd, args });
|
|
if (args.includes('--version')) return createFakeChild('2.1.133 (Claude Code)\n');
|
|
return createFakeChild(streamJson);
|
|
};
|
|
|
|
const result = await t.testBackend(
|
|
{
|
|
...baseSettings,
|
|
backend: 'claude-code',
|
|
cliPath: '/usr/bin/claude',
|
|
cliTimeoutMs: 300000,
|
|
model: 'claude-sonnet-4-6',
|
|
promptLanguage: 'zh',
|
|
minCards: 5,
|
|
maxCards: 15,
|
|
maxDocChars: 100000,
|
|
},
|
|
{ spawnImpl },
|
|
);
|
|
|
|
assert.match(result, /claude @ \/usr\/bin\/claude/, 'result shows resolved Claude command');
|
|
assert.match(result, /2\.1\.133/, 'result includes CLI version output');
|
|
assert.match(result, /smoke: 1 card/, 'result includes parsed smoke-card count');
|
|
assert.deepStrictEqual(calls[0].args, ['--version'], 'first Claude call checks version');
|
|
|
|
const smokeArgs = calls[1].args;
|
|
assert.strictEqual(smokeArgs[0], '-p', 'Claude smoke uses print mode');
|
|
assert.strictEqual(
|
|
smokeArgs[smokeArgs.indexOf('--output-format') + 1],
|
|
'stream-json',
|
|
'Claude smoke uses stream-json for incremental output',
|
|
);
|
|
assert.ok(
|
|
smokeArgs.includes('--verbose'),
|
|
'Claude smoke must include --verbose (required alongside stream-json on CLI 2.1.131+)',
|
|
);
|
|
assert.strictEqual(smokeArgs[smokeArgs.indexOf('--tools') + 1], '', 'Claude smoke disables tools');
|
|
assert.ok(smokeArgs.includes('--strict-mcp-config'), 'Claude smoke ignores user/project MCP servers');
|
|
assert.strictEqual(
|
|
smokeArgs[smokeArgs.indexOf('--mcp-config') + 1],
|
|
'{"mcpServers":{}}',
|
|
'Claude smoke passes an empty MCP config',
|
|
);
|
|
}
|
|
|
|
async function testCodexBackendRunsVersionAndSmoke() {
|
|
const calls = [];
|
|
const resultJson =
|
|
'{"cards":[{"title":"CLI smoke","anchor":"parallel reader smoke anchor","gist":"ok","bullets":["ok"]}]}';
|
|
const spawnImpl = (cmd, args) => {
|
|
calls.push({ cmd, args });
|
|
if (args.includes('--version')) return createFakeChild('codex-cli 1.2.3\n');
|
|
return createFakeChild(resultJson);
|
|
};
|
|
|
|
const result = await t.testBackend(
|
|
{
|
|
...baseSettings,
|
|
backend: 'codex',
|
|
cliPath: '/usr/bin/codex',
|
|
cliTimeoutMs: 300000,
|
|
promptLanguage: 'zh',
|
|
minCards: 5,
|
|
maxCards: 15,
|
|
maxDocChars: 100000,
|
|
},
|
|
{ spawnImpl },
|
|
);
|
|
|
|
assert.match(result, /codex @ \/usr\/bin\/codex/, 'result shows resolved Codex command');
|
|
assert.match(result, /codex-cli 1\.2\.3/, 'result includes CLI version output');
|
|
assert.match(result, /smoke: 1 card/, 'result includes parsed smoke-card count');
|
|
assert.deepStrictEqual(calls[0].args, ['--version'], 'first Codex call checks version');
|
|
assert.strictEqual(calls[1].args[0], 'exec', 'Codex smoke runs through codex exec');
|
|
assert.strictEqual(calls[1].args[calls[1].args.indexOf('--sandbox') + 1], 'read-only', 'Codex smoke is read-only');
|
|
}
|
|
|
|
async function testApiBackendUsesInjectedRequestUrl() {
|
|
let called = false;
|
|
const result = await t.testBackend(
|
|
{
|
|
...baseSettings,
|
|
apiHeaders: '',
|
|
maxDocChars: 100000,
|
|
promptLanguage: 'zh',
|
|
minCards: 5,
|
|
maxCards: 15,
|
|
},
|
|
{
|
|
requestUrlImpl: async () => {
|
|
called = true;
|
|
return openAiCardsResponse([]);
|
|
},
|
|
},
|
|
);
|
|
|
|
assert.strictEqual(called, true, 'API backend test should use injected requestUrl');
|
|
assert.strictEqual(result, 'OpenAI / OpenAI Chat Completions', 'API backend test reports provider and format');
|
|
}
|
|
|
|
// Regression: smoke flow must propagate the canary stderr that real Claude CLI
|
|
// emits when --output-format=stream-json is passed without --verbose.
|
|
async function testClaudeBackendSurfacesVerboseError() {
|
|
const spawnImpl = (_cmd, args) => {
|
|
if (args.includes('--version')) return createFakeChild('2.1.133 (Claude Code)\n');
|
|
// smoke call: simulate the exact stderr the real CLI emits when --verbose is missing
|
|
return createFakeChild('', 'Error: When using --print, --output-format=stream-json requires --verbose', 1);
|
|
};
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
t.testBackend(
|
|
{
|
|
...baseSettings,
|
|
backend: 'claude-code',
|
|
cliPath: '/usr/bin/claude',
|
|
cliTimeoutMs: 300000,
|
|
model: 'claude-sonnet-4-6',
|
|
promptLanguage: 'zh',
|
|
minCards: 5,
|
|
maxCards: 15,
|
|
maxDocChars: 100000,
|
|
},
|
|
{ spawnImpl },
|
|
),
|
|
(err) => {
|
|
assert.ok(err instanceof t.CliProcessError, 'verbose-regression smoke surfaces CliProcessError');
|
|
assert.match(err.details.stderrTail, /requires --verbose/, 'stderr tail preserves the verbose-flag hint');
|
|
assert.strictEqual(err.details.exitCode, 1, 'preserves CLI exit code so UI can classify');
|
|
return true;
|
|
},
|
|
'testBackend must propagate Claude smoke failures (including verbose-flag regression) as structured errors',
|
|
);
|
|
}
|
|
|
|
// Regression: if `<cli> --version` itself fails, testBackend must throw before reaching smoke,
|
|
// not silently report success with garbage version output.
|
|
async function testCodexBackendPropagatesVersionFailure() {
|
|
const calls = [];
|
|
const spawnImpl = (_cmd, args) => {
|
|
calls.push(args);
|
|
if (args.includes('--version')) return createFakeChild('', 'codex: command not found', 127);
|
|
return createFakeChild('{"cards":[]}');
|
|
};
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
t.testBackend(
|
|
{
|
|
...baseSettings,
|
|
backend: 'codex',
|
|
cliPath: '/usr/bin/codex',
|
|
cliTimeoutMs: 300000,
|
|
promptLanguage: 'zh',
|
|
minCards: 5,
|
|
maxCards: 15,
|
|
maxDocChars: 100000,
|
|
},
|
|
{ spawnImpl },
|
|
),
|
|
(err) => {
|
|
assert.ok(err instanceof t.CliProcessError, 'version failure surfaces structured CliProcessError');
|
|
assert.strictEqual(err.details.exitCode, 127, 'preserves shell command-not-found exit code');
|
|
return true;
|
|
},
|
|
'testBackend must not swallow version-probe failures',
|
|
);
|
|
assert.strictEqual(calls.length, 1, 'smoke call must NOT run after version probe fails');
|
|
assert.deepStrictEqual(calls[0], ['--version'], 'only the version probe should have been spawned');
|
|
}
|
|
|
|
(async () => {
|
|
await testClaudeBackendRunsVersionAndSmoke();
|
|
await testClaudeBackendSurfacesVerboseError();
|
|
await testCodexBackendRunsVersionAndSmoke();
|
|
await testCodexBackendPropagatesVersionFailure();
|
|
await testApiBackendUsesInjectedRequestUrl();
|
|
console.log('backend-test tests passed');
|
|
})().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|