fancive_obsidian-parallel-r.../tests/cli.test.js

793 lines
28 KiB
JavaScript
Raw Permalink Normal View History

const { assert, t, EventEmitter } = require('./test-setup');
// ── resolveCliPath ──
assert.strictEqual(t.resolveCliPath('claude', ' /usr/bin/claude '), '/usr/bin/claude', 'trims override path');
assert.strictEqual(t.resolveCliPath('claude', '/custom/path'), '/custom/path', 'returns custom path unchanged');
const fsModule = require('fs');
const origExistsSync = fsModule.existsSync;
const mockPath = require('path').join(require('os').homedir(), '.local', 'bin', 'claude');
fsModule.existsSync = (p) => p === mockPath;
try {
const resolved = t.resolveCliPath('claude', '');
assert.strictEqual(resolved, mockPath, 'resolveCliPath finds binary in mocked ~/.local/bin');
} finally {
fsModule.existsSync = origExistsSync;
}
fsModule.existsSync = () => false;
try {
const fallback = t.resolveCliPath('claude', '');
assert.strictEqual(fallback, 'claude', 'resolveCliPath falls back to bare name when not found');
} finally {
fsModule.existsSync = origExistsSync;
}
// ── runCli edge cases ──
function createFakeChild() {
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.killedWith = null;
child.kill = (signal) => {
child.killedWith = signal;
return true;
};
return child;
}
async function testRunCliEdgeCases() {
const timeoutChild = createFakeChild();
timeoutChild.pid = 4242;
await assert.rejects(
() => {
// emit some bytes before timeout so we can verify the diagnostic suffix carries them
setImmediate(() => {
timeoutChild.stderr.emit('data', Buffer.from('boom: connection reset'));
});
return t.runCli('fake', [], '', 20, undefined, () => timeoutChild);
},
(err) => {
assert.match(err.message, /CLI timed out \(20ms\)/, 'wall-clock timeout keeps original prefix');
assert.match(err.message, /pid=4242/, 'timeout error carries pid');
assert.match(err.message, /stderr=\d+B/, 'timeout error carries stderr byte count');
assert.match(err.message, /stderr tail/, 'timeout error includes stderr tail');
assert.match(err.message, /boom: connection reset/, 'timeout error preserves last stderr line');
feat(error-ui): structured CliProcessError + ErrorKind-driven UI dispatch Replaces the single long-Notice failure UX with a kind-aware dispatcher so users get actionable buttons instead of a wall of text. - CliProcessError extends Error with a typed CliErrorDetails payload (reason / pid / elapsed / idle / bytes / tails / exitCode / signal / timeoutMs / idleTimeoutMs). All five runCli failure paths now throw the typed error; message format is preserved so existing classifier regex and tests still match. - classifyGenerationError short-circuits on details.reason for the deterministic cases (wall/idle-timeout → timeout, spawn/startup → config, streams-unavailable → unknown). exit-nonzero falls through so stderr-derived auth/rate-limit hints still classify correctly. Duck-typed check avoids cli ↔ generation-job-manager circular import. - New error-ui.ts dispatches by ErrorKind: timeout (with details) → TimeoutDiagnosticsModal showing cmd, pid, elapsed, idle, bytes, redacted stderr/stdout tail, plus copy / open-settings / close buttons. timeout (no details, e.g. API streaming) → actionable Notice. auth/config → Notice + "Open Settings" CTA. rate-limit → Notice + "Copy details". schema → Notice + "Copy raw output" CTA. unknown → unchanged legacy short Notice. - main.ts handleGenerationError delegates to dispatcher; new openPluginSettings() helper uses Obsidian's app.setting.openTabById. - 21 i18n strings (zh + en) for all new UI labels and the modal copy. - styles.css adds error notice + diagnostics modal styling. - 7 new test assertions cover typed details on every failure path and classifyGenerationError respecting structured reasons. Change-Id: I8af46d375d92ba26d14180ffbeb6c3a6dedd89f5
2026-05-05 12:19:57 +00:00
// Structured CliProcessError details (P0 #2: error UI dispatch needs typed access).
assert.ok(err instanceof t.CliProcessError, 'wall-timeout throws CliProcessError');
assert.strictEqual(err.details.reason, 'wall-timeout', 'wall-timeout reason set');
assert.strictEqual(err.details.pid, 4242, 'details.pid populated');
assert.ok(err.details.stderrBytes >= 'boom: connection reset'.length, 'details.stderrBytes utf-8 byte count');
assert.match(err.details.stderrTail, /boom: connection reset/, 'details.stderrTail preserved');
assert.strictEqual(err.details.timeoutMs, 20, 'details.timeoutMs reflects wall timeout');
return true;
},
'runCli rejects on timeout with rich diagnostics',
);
assert.strictEqual(timeoutChild.killedWith, 'SIGKILL', 'runCli kills timed out processes');
const cancelChild = createFakeChild();
let cancelHandler = null;
const cancelPromise = t.runCli(
'fake',
[],
'',
1000,
{
key: 'cancel.md',
onCancel: (handler) => {
cancelHandler = handler;
},
},
() => cancelChild,
);
cancelHandler();
await assert.rejects(
() => cancelPromise,
(err) => err && err.code === 'cancelled' && err.key === 'cancel.md',
'runCli rejects with GenerationJobCancelledError on cancellation',
);
assert.strictEqual(cancelChild.killedWith, 'SIGKILL', 'runCli kills cancelled processes');
await assert.rejects(
() => t.runCli('fake', [], '', 100, undefined, () => ({ stdout: null, stderr: null, stdin: null })),
feat(error-ui): structured CliProcessError + ErrorKind-driven UI dispatch Replaces the single long-Notice failure UX with a kind-aware dispatcher so users get actionable buttons instead of a wall of text. - CliProcessError extends Error with a typed CliErrorDetails payload (reason / pid / elapsed / idle / bytes / tails / exitCode / signal / timeoutMs / idleTimeoutMs). All five runCli failure paths now throw the typed error; message format is preserved so existing classifier regex and tests still match. - classifyGenerationError short-circuits on details.reason for the deterministic cases (wall/idle-timeout → timeout, spawn/startup → config, streams-unavailable → unknown). exit-nonzero falls through so stderr-derived auth/rate-limit hints still classify correctly. Duck-typed check avoids cli ↔ generation-job-manager circular import. - New error-ui.ts dispatches by ErrorKind: timeout (with details) → TimeoutDiagnosticsModal showing cmd, pid, elapsed, idle, bytes, redacted stderr/stdout tail, plus copy / open-settings / close buttons. timeout (no details, e.g. API streaming) → actionable Notice. auth/config → Notice + "Open Settings" CTA. rate-limit → Notice + "Copy details". schema → Notice + "Copy raw output" CTA. unknown → unchanged legacy short Notice. - main.ts handleGenerationError delegates to dispatcher; new openPluginSettings() helper uses Obsidian's app.setting.openTabById. - 21 i18n strings (zh + en) for all new UI labels and the modal copy. - styles.css adds error notice + diagnostics modal styling. - 7 new test assertions cover typed details on every failure path and classifyGenerationError respecting structured reasons. Change-Id: I8af46d375d92ba26d14180ffbeb6c3a6dedd89f5
2026-05-05 12:19:57 +00:00
(err) => {
assert.match(err.message, /CLI process streams are unavailable/, 'streams-unavailable message preserved');
assert.ok(err instanceof t.CliProcessError, 'streams-unavailable throws CliProcessError');
assert.strictEqual(err.details.reason, 'streams-unavailable', 'streams-unavailable reason set');
return true;
},
'runCli reports unavailable stdio streams',
);
feat(error-ui): structured CliProcessError + ErrorKind-driven UI dispatch Replaces the single long-Notice failure UX with a kind-aware dispatcher so users get actionable buttons instead of a wall of text. - CliProcessError extends Error with a typed CliErrorDetails payload (reason / pid / elapsed / idle / bytes / tails / exitCode / signal / timeoutMs / idleTimeoutMs). All five runCli failure paths now throw the typed error; message format is preserved so existing classifier regex and tests still match. - classifyGenerationError short-circuits on details.reason for the deterministic cases (wall/idle-timeout → timeout, spawn/startup → config, streams-unavailable → unknown). exit-nonzero falls through so stderr-derived auth/rate-limit hints still classify correctly. Duck-typed check avoids cli ↔ generation-job-manager circular import. - New error-ui.ts dispatches by ErrorKind: timeout (with details) → TimeoutDiagnosticsModal showing cmd, pid, elapsed, idle, bytes, redacted stderr/stdout tail, plus copy / open-settings / close buttons. timeout (no details, e.g. API streaming) → actionable Notice. auth/config → Notice + "Open Settings" CTA. rate-limit → Notice + "Copy details". schema → Notice + "Copy raw output" CTA. unknown → unchanged legacy short Notice. - main.ts handleGenerationError delegates to dispatcher; new openPluginSettings() helper uses Obsidian's app.setting.openTabById. - 21 i18n strings (zh + en) for all new UI labels and the modal copy. - styles.css adds error notice + diagnostics modal styling. - 7 new test assertions cover typed details on every failure path and classifyGenerationError respecting structured reasons. Change-Id: I8af46d375d92ba26d14180ffbeb6c3a6dedd89f5
2026-05-05 12:19:57 +00:00
// Non-zero exit still throws a typed CliProcessError so dispatcher can inspect stderr.
const exitChild = createFakeChild();
exitChild.pid = 7373;
const exitPromise = t.runCli('fake', [], '', 1000, undefined, () => exitChild);
setImmediate(() => {
exitChild.stderr.emit('data', Buffer.from('error: invalid api key'));
exitChild.emit('close', 2, null);
});
await assert.rejects(
() => exitPromise,
(err) => {
assert.ok(err instanceof t.CliProcessError, 'exit-nonzero throws CliProcessError');
assert.strictEqual(err.details.reason, 'exit-nonzero', 'exit-nonzero reason set');
assert.strictEqual(err.details.exitCode, 2, 'details.exitCode populated');
assert.match(err.details.stderrTail, /invalid api key/, 'details.stderrTail captures auth hint');
return true;
},
'runCli exposes non-zero exit code in structured details',
);
// Diagnostic suffix must redact secrets that show up in stderr (Bearer tokens, sk- keys, etc.)
const secretChild = createFakeChild();
secretChild.pid = 6363;
await assert.rejects(
() => {
setImmediate(() => {
secretChild.stderr.emit(
'data',
Buffer.from('Authorization: Bearer sk-ant-supersecrettoken-xyz123 failed handshake'),
);
});
return t.runCli('fake', [], '', 20, undefined, () => secretChild);
},
(err) => {
assert.doesNotMatch(err.message, /sk-ant-supersecrettoken-xyz123/, 'token must not appear verbatim');
assert.match(err.message, /\[REDACTED\]/, 'redaction marker present');
assert.match(err.message, /failed handshake/, 'non-secret context preserved');
return true;
},
'runCli timeout suffix redacts secrets',
);
}
// ── summarizeViaClaudeCode args validation ──
// Known flags supported by the Claude Code CLI (from `claude --help`).
const VALID_CLAUDE_FLAGS = new Set([
'--add-dir',
'--agent',
'--agents',
'--allow-dangerously-skip-permissions',
'--allowedTools',
'--allowed-tools',
'--append-system-prompt',
'--bare',
'--betas',
'--brief',
'--chrome',
'--dangerously-skip-permissions',
'--debug-file',
'--disable-slash-commands',
'--disallowedTools',
'--disallowed-tools',
'--effort',
'--exclude-dynamic-system-prompt-sections',
'--fallback-model',
'--file',
'--fork-session',
'--from-pr',
'--ide',
'--include-hook-events',
'--include-partial-messages',
'--input-format',
'--json-schema',
'--max-budget-usd',
'--mcp-config',
'--mcp-debug',
'--model',
'--no-chrome',
'--no-session-persistence',
'--output-format',
'--permission-mode',
'--plugin-dir',
'--remote-control-session-name-prefix',
'--replay-user-messages',
'--session-id',
'--setting-sources',
'--settings',
'--strict-mcp-config',
'--system-prompt',
'--tmux',
'--tools',
'--verbose',
'-p',
'-c',
'-d',
'-h',
'-n',
'-r',
'-v',
'-w',
]);
async function testClaudeCodeArgs() {
let capturedArgs = null;
const resultJson = JSON.stringify([
{
type: 'result',
result: '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
},
]);
const fakeSpawn = (_cmd, args) => {
capturedArgs = args;
const child = createFakeChild();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from(resultJson));
child.emit('close', 0);
});
return child;
};
const settings = {
backend: 'claude-code',
cliPath: '/usr/bin/claude',
apiMaxTokens: 8192,
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
};
await t.summarizeViaClaudeCode('system prompt', 'user content', settings, undefined, fakeSpawn);
assert.ok(capturedArgs, 'summarizeViaClaudeCode should have called spawn');
// Verify every --flag in the args is a valid Claude CLI flag
const flags = capturedArgs.filter((a) => a.startsWith('-'));
for (const flag of flags) {
assert.ok(VALID_CLAUDE_FLAGS.has(flag), `summarizeViaClaudeCode passes unsupported flag "${flag}" to claude CLI`);
}
// Verify key expected flags are present
assert.ok(capturedArgs.includes('-p'), 'should include -p (print mode)');
assert.ok(capturedArgs.includes('--output-format'), 'should include --output-format');
assert.strictEqual(
capturedArgs[capturedArgs.indexOf('--output-format') + 1],
'stream-json',
'claude output should stream JSON events',
);
assert.ok(
capturedArgs.includes('--verbose'),
'stream-json requires --verbose in Claude CLI 2.1.131+',
);
assert.ok(capturedArgs.includes('--append-system-prompt'), 'should include --append-system-prompt');
assert.ok(capturedArgs.includes('--tools'), 'should explicitly restrict Claude tools');
assert.strictEqual(capturedArgs[capturedArgs.indexOf('--tools') + 1], '', 'Claude tools should be disabled');
assert.ok(capturedArgs.includes('--disallowed-tools'), 'should include --disallowed-tools');
assert.ok(capturedArgs.includes('--no-session-persistence'), 'should avoid session persistence for plugin calls');
assert.ok(capturedArgs.includes('--disable-slash-commands'), 'should disable slash commands for plugin calls');
assert.ok(capturedArgs.includes('--no-chrome'), 'should suppress Claude TUI chrome');
assert.ok(capturedArgs.includes('--strict-mcp-config'), 'should ignore user/project MCP servers');
assert.strictEqual(
capturedArgs[capturedArgs.indexOf('--mcp-config') + 1],
'{"mcpServers":{}}',
'--mcp-config must pair with an empty MCP server map',
);
assert.strictEqual(
capturedArgs[capturedArgs.indexOf('--disallowed-tools') + 1],
'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task,LSP,NotebookEdit',
'--disallowed-tools must list every tool that could exfiltrate or mutate',
);
// Ordering: -p must come before --output-format so Claude treats the run as print-mode.
assert.ok(
capturedArgs.indexOf('-p') < capturedArgs.indexOf('--output-format'),
'-p must appear before --output-format',
);
// --verbose must accompany --output-format stream-json (Claude CLI 2.1.131+ requirement).
// The CLI does not constrain position; we only check that --verbose is not consumed as the value of
// another flag — i.e. it must not appear at index `--output-format` + 1 (where 'stream-json' belongs).
const outputFmtIdx = capturedArgs.indexOf('--output-format');
const verboseIdx = capturedArgs.indexOf('--verbose');
assert.notStrictEqual(verboseIdx, outputFmtIdx + 1, '--verbose must not be parsed as the --output-format value');
// Verify --max-tokens is NOT present (it's not a valid claude CLI flag)
assert.ok(!capturedArgs.includes('--max-tokens'), 'should NOT include --max-tokens');
}
async function testClaudeCodeStreamJsonOutput() {
const streamJson = [
JSON.stringify({ type: 'system', subtype: 'init', tools: ['LSP'] }),
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
}),
'',
].join('\n');
const fakeSpawn = () => {
const child = createFakeChild();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from(streamJson));
child.emit('close', 0);
});
return child;
};
const settings = {
backend: 'claude-code',
cliPath: '/usr/bin/claude',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
};
const cards = await t.summarizeViaClaudeCode('system prompt', 'user content', settings, undefined, fakeSpawn);
assert.strictEqual(cards.length, 1, 'claude stream-json result event is parsed');
assert.strictEqual(cards[0].title, 'T', 'parsed card title preserved');
}
// ── summarizeViaCodex args validation ──
// Known flags/subcommands supported by `codex exec` (from `codex exec --help`).
const VALID_CODEX_EXEC_FLAGS = new Set([
'-c',
'--config',
'--enable',
'--disable',
'-i',
'--image',
'-m',
'--model',
'--oss',
'--local-provider',
'-p',
'--profile',
'-s',
'--sandbox',
'--full-auto',
'--dangerously-bypass-approvals-and-sandbox',
'-C',
'--cd',
'--add-dir',
'--skip-git-repo-check',
'--ephemeral',
'--ignore-user-config',
'--ignore-rules',
'--output-schema',
'--color',
'--json',
'-o',
'--output-last-message',
'-h',
'--help',
'-V',
'--version',
]);
async function testCodexArgs() {
let capturedArgs = null;
const resultJson = '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}';
const fakeSpawn = (_cmd, args) => {
capturedArgs = args;
const child = createFakeChild();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from(resultJson));
child.emit('close', 0);
});
return child;
};
const settings = {
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
};
await t.summarizeViaCodex('system prompt', 'user content', settings, undefined, fakeSpawn);
assert.ok(capturedArgs, 'summarizeViaCodex should have called spawn');
// First arg must be the 'exec' subcommand
assert.strictEqual(capturedArgs[0], 'exec', 'first arg should be exec subcommand');
// Verify every --flag (after 'exec') is valid for `codex exec`
const flags = capturedArgs.slice(1).filter((a) => a.startsWith('-') && a !== '-');
for (const flag of flags) {
assert.ok(VALID_CODEX_EXEC_FLAGS.has(flag), `summarizeViaCodex passes unsupported flag "${flag}" to codex exec`);
}
// Verify key expected flags are present
assert.ok(capturedArgs.includes('--skip-git-repo-check'), 'should include --skip-git-repo-check');
// '-' means read prompt from stdin
assert.ok(capturedArgs.includes('-'), 'should include - (read from stdin)');
// Verify stdin content combines system and user prompts
assert.ok(capturedArgs.includes('exec'), 'should include exec subcommand');
// Verify sandbox is read-only (security: prevent prompt injection from running tools)
const sandboxIdx = capturedArgs.indexOf('--sandbox');
assert.ok(sandboxIdx >= 0, 'should pass --sandbox to codex exec');
assert.strictEqual(capturedArgs[sandboxIdx + 1], 'read-only', 'sandbox value must be read-only');
// Codex never receives --model (settings.model defaults to a Claude-name and would break codex)
assert.ok(!capturedArgs.includes('--model'), 'codex args must not include --model (defers to codex config)');
}
async function testCodexArgsIgnoresClaudeDefaultModel() {
let capturedArgs = null;
const resultJson = '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}';
const fakeSpawn = (_cmd, args) => {
capturedArgs = args;
const child = createFakeChild();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from(resultJson));
child.emit('close', 0);
});
return child;
};
// Even if settings.model is set (e.g. user has DEFAULT_SETTINGS.model = 'claude-sonnet-4-6'),
// codex must NOT receive --model.
const settings = {
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 5000,
model: 'claude-sonnet-4-6',
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
};
await t.summarizeViaCodex('system prompt', 'user content', settings, undefined, fakeSpawn);
assert.ok(!capturedArgs.includes('--model'), 'codex must ignore settings.model regardless of value');
}
async function testClaudeCodeArgsWithModel() {
let capturedArgs = null;
const resultJson = JSON.stringify([
{
type: 'result',
result: '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
},
]);
const fakeSpawn = (_cmd, args) => {
capturedArgs = args;
const child = createFakeChild();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from(resultJson));
child.emit('close', 0);
});
return child;
};
const baseSettings = {
backend: 'claude-code',
cliPath: '/usr/bin/claude',
apiMaxTokens: 8192,
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 5,
maxCards: 15,
maxDocChars: 100000,
};
await t.summarizeViaClaudeCode('system prompt', 'user content', baseSettings, undefined, fakeSpawn);
assert.ok(!capturedArgs.includes('--model'), 'claude args omit --model when settings.model unset');
await t.summarizeViaClaudeCode('system', 'user', { ...baseSettings, model: 'claude-opus-4-7' }, undefined, fakeSpawn);
const modelIdx = capturedArgs.indexOf('--model');
assert.ok(modelIdx >= 0, 'claude should pass --model when settings.model set');
assert.strictEqual(capturedArgs[modelIdx + 1], 'claude-opus-4-7', 'claude --model value matches settings.model');
}
// ── Claude stream-json parse resilience ──
async function runClaudeCodeWithStdout(stdoutText) {
const fakeSpawn = () => {
const child = createFakeChild();
process.nextTick(() => {
if (stdoutText) child.stdout.emit('data', Buffer.from(stdoutText));
child.emit('close', 0);
});
return child;
};
const settings = {
backend: 'claude-code',
cliPath: '/usr/bin/claude',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
return t.summarizeViaClaudeCode('system', 'user', settings, undefined, fakeSpawn);
}
async function testClaudeCodeStreamJsonResilience() {
// B1: NDJSON with non-JSON banner line — must skip banner and still parse the result event.
const withBanner = [
'Claude Code 2.1.131',
JSON.stringify({ type: 'system', subtype: 'init', tools: ['LSP'] }),
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"B1","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
}),
].join('\n');
const b1 = await runClaudeCodeWithStdout(withBanner);
assert.strictEqual(b1.length, 1, 'B1: banner line is skipped and result parsed');
assert.strictEqual(b1[0].title, 'B1', 'B1: card title preserved');
// B2: Missing result event — must throw a recognizable "no result" error (not a crash inside parser).
// Match both zh ("没有返回结果") and en ("returned no result") so the assertion survives translate() fallback differences.
const noResultRegex = /没有返回结果|returned no result/i;
const onlyInit = JSON.stringify({ type: 'system', subtype: 'init', tools: [] }) + '\n';
await assert.rejects(
() => runClaudeCodeWithStdout(onlyInit),
(err) => noResultRegex.test(err.message),
'B2: missing result event throws a descriptive "no result" error',
);
// B3: Empty stdout — same behavior: must throw, not crash on undefined / silent success.
await assert.rejects(
() => runClaudeCodeWithStdout(''),
(err) => noResultRegex.test(err.message),
'B3: empty stdout throws a descriptive "no result" error',
);
// B4: Multiple result events — last one wins (claudeResultText scans in reverse).
const twoResults = [
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"FIRST","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
}),
JSON.stringify({
type: 'result',
result: '{"cards":[{"title":"LAST","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
}),
].join('\n');
const b4 = await runClaudeCodeWithStdout(twoResults);
assert.strictEqual(b4.length, 1, 'B4: latest result event is used');
assert.strictEqual(b4[0].title, 'LAST', 'B4: last result wins over earlier ones');
// B5: Single-event object with `content` (not `result`) — covers the legacy fallback path
// where claudeResultText reads `events[0].content` when no `type: result` event exists.
const contentFallback = JSON.stringify({
content: '{"cards":[{"title":"B5","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}',
});
const b5 = await runClaudeCodeWithStdout(contentFallback);
assert.strictEqual(b5.length, 1, 'B5: events[0].content fallback parses card');
assert.strictEqual(b5[0].title, 'B5', 'B5: content-fallback card title preserved');
}
// ── Claude verbose-missing error-path plumbing ──
// This does NOT guard the --verbose flag itself (that is locked by
// `testClaudeCodeArgs`'s `capturedArgs.includes('--verbose')` assertion, which fails
// in fake-spawn before any stderr is even simulated).
// Instead, this test simulates the *real* stderr that Claude CLI emits when
// --verbose is missing, and asserts that the error-propagation pipeline preserves
// exit code + stderr tail so the UI can render a useful diagnostic. If we ever
// rework error handling and accidentally swallow CLI stderr, this catches it.
async function testClaudeCodeVerboseRegressionCanary() {
const fakeSpawn = () => {
const child = createFakeChild();
process.nextTick(() => {
child.stderr.emit(
'data',
Buffer.from('Error: When using --print, --output-format=stream-json requires --verbose'),
);
child.emit('close', 1, null);
});
return child;
};
const settings = {
backend: 'claude-code',
cliPath: '/usr/bin/claude',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
await assert.rejects(
() => t.summarizeViaClaudeCode('system', 'user', settings, undefined, fakeSpawn),
(err) => {
assert.ok(err instanceof t.CliProcessError, 'canary: throws CliProcessError on exit-nonzero');
assert.strictEqual(err.details.reason, 'exit-nonzero', 'canary: exit-nonzero reason');
assert.strictEqual(err.details.exitCode, 1, 'canary: surfaces CLI exit code');
assert.match(err.details.stderrTail, /requires --verbose/, 'canary: preserves verbose-flag hint in stderr');
return true;
},
'Claude CLI verbose-missing stderr surfaces as a structured CliProcessError',
);
}
// ── Codex error path ──
async function testCodexErrorPath() {
const fakeSpawn = () => {
const child = createFakeChild();
process.nextTick(() => {
child.stderr.emit('data', Buffer.from('error: not authenticated, please run codex login'));
child.emit('close', 2, null);
});
return child;
};
const settings = {
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
await assert.rejects(
() => t.summarizeViaCodex('system', 'user', settings, undefined, fakeSpawn),
(err) => {
assert.ok(err instanceof t.CliProcessError, 'codex: throws CliProcessError on exit-nonzero');
assert.strictEqual(err.details.exitCode, 2, 'codex: exit code preserved');
assert.match(err.details.stderrTail, /not authenticated/, 'codex: stderr tail preserves auth hint');
return true;
},
'codex non-zero exit propagates structured error',
);
}
// ── Codex args minimal contract ──
// Lock in the exact minimal arg shape: any drift means we are leaking new flags
// into a CLI that is intentionally kept minimal (no --model, no --cd, no -c).
async function testCodexArgsMinimalContract() {
let capturedArgs = null;
const resultJson = '{"cards":[{"title":"T","anchor":"hello world test anchor","gist":"G","bullets":["B"]}]}';
const fakeSpawn = (_cmd, args) => {
capturedArgs = args;
const child = createFakeChild();
process.nextTick(() => {
child.stdout.emit('data', Buffer.from(resultJson));
child.emit('close', 0);
});
return child;
};
const settings = {
backend: 'codex',
cliPath: '/usr/bin/codex',
cliTimeoutMs: 5000,
promptLanguage: 'zh',
minCards: 1,
maxCards: 15,
maxDocChars: 100000,
};
await t.summarizeViaCodex('system', 'user', settings, undefined, fakeSpawn);
// Exact list lock: changes here are intentional and require updating the test.
assert.deepStrictEqual(
capturedArgs,
['exec', '--skip-git-repo-check', '--sandbox', 'read-only', '-'],
'codex exec args must remain minimal (exec + skip-git + read-only sandbox + stdin marker)',
);
}
// ── classifyGenerationError ──
assert.strictEqual(t.classifyGenerationError(new Error('API key 未设置')), 'auth');
assert.strictEqual(t.classifyGenerationError(new Error('API key is not set')), 'auth');
assert.strictEqual(t.classifyGenerationError(new Error('CLI timed out (120000ms)')), 'timeout');
assert.strictEqual(t.classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout');
feat(error-ui): structured CliProcessError + ErrorKind-driven UI dispatch Replaces the single long-Notice failure UX with a kind-aware dispatcher so users get actionable buttons instead of a wall of text. - CliProcessError extends Error with a typed CliErrorDetails payload (reason / pid / elapsed / idle / bytes / tails / exitCode / signal / timeoutMs / idleTimeoutMs). All five runCli failure paths now throw the typed error; message format is preserved so existing classifier regex and tests still match. - classifyGenerationError short-circuits on details.reason for the deterministic cases (wall/idle-timeout → timeout, spawn/startup → config, streams-unavailable → unknown). exit-nonzero falls through so stderr-derived auth/rate-limit hints still classify correctly. Duck-typed check avoids cli ↔ generation-job-manager circular import. - New error-ui.ts dispatches by ErrorKind: timeout (with details) → TimeoutDiagnosticsModal showing cmd, pid, elapsed, idle, bytes, redacted stderr/stdout tail, plus copy / open-settings / close buttons. timeout (no details, e.g. API streaming) → actionable Notice. auth/config → Notice + "Open Settings" CTA. rate-limit → Notice + "Copy details". schema → Notice + "Copy raw output" CTA. unknown → unchanged legacy short Notice. - main.ts handleGenerationError delegates to dispatcher; new openPluginSettings() helper uses Obsidian's app.setting.openTabById. - 21 i18n strings (zh + en) for all new UI labels and the modal copy. - styles.css adds error notice + diagnostics modal styling. - 7 new test assertions cover typed details on every failure path and classifyGenerationError respecting structured reasons. Change-Id: I8af46d375d92ba26d14180ffbeb6c3a6dedd89f5
2026-05-05 12:19:57 +00:00
// Structured CliProcessError short-circuits message-regex matching for deterministic reasons.
const wallTimeout = new t.CliProcessError('whatever message text', {
reason: 'wall-timeout',
cmd: 'claude',
pid: 1,
elapsedMs: 0,
idleMs: 0,
stdoutBytes: 0,
stderrBytes: 0,
stdoutTail: '',
stderrTail: '',
exitCode: null,
signal: null,
timeoutMs: 0,
});
assert.strictEqual(t.classifyGenerationError(wallTimeout), 'timeout', 'wall-timeout details → timeout kind');
const spawnFailure = new t.CliProcessError('Failed to start claude', {
reason: 'spawn-failure',
cmd: 'claude',
pid: null,
elapsedMs: 0,
idleMs: null,
stdoutBytes: 0,
stderrBytes: 0,
stdoutTail: '',
stderrTail: '',
exitCode: null,
signal: null,
timeoutMs: 0,
});
assert.strictEqual(t.classifyGenerationError(spawnFailure), 'config', 'spawn-failure details → config kind');
// exit-nonzero falls through so stderr-derived auth/rate-limit messages can still classify.
const exitWithAuthStderr = new t.CliProcessError('CLI exited with code 1\nstderr:\nerror: API key invalid', {
reason: 'exit-nonzero',
cmd: 'claude',
pid: 1,
elapsedMs: 0,
idleMs: 0,
stdoutBytes: 0,
stderrBytes: 0,
stdoutTail: '',
stderrTail: '',
exitCode: 1,
signal: null,
timeoutMs: 0,
});
assert.strictEqual(
t.classifyGenerationError(exitWithAuthStderr),
'auth',
'exit-nonzero falls through so message-regex still detects auth',
);
assert.strictEqual(t.classifyGenerationError(new Error('API returned HTTP 429')), 'rate-limit');
assert.strictEqual(t.classifyGenerationError(new Error('json_schema validation failed')), 'schema');
assert.strictEqual(t.classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema');
assert.strictEqual(
t.classifyGenerationError(new Error('LLM returned non-JSON (123 chars). See console for excerpt.')),
'schema',
'EN non-JSON message classified as schema',
);
assert.strictEqual(
t.classifyGenerationError(new Error('Claude CLI returned unexpected output (200 chars).')),
'schema',
'EN unexpected output classified as schema',
);
assert.strictEqual(
t.classifyGenerationError(new Error('Claude CLI returned no result (0 chars).')),
'schema',
'EN no result classified as schema',
);
assert.strictEqual(
t.classifyGenerationError(new Error('Claude CLI 返回了非预期输出(长度 200 字符)')),
'schema',
'zh unexpected-output message classified as schema',
);
assert.strictEqual(
t.classifyGenerationError(new Error('Claude CLI 没有返回结果(长度 0 字符)')),
'schema',
'zh no-result message classified as schema',
);
assert.strictEqual(t.classifyGenerationError(new Error('bad config value')), 'config');
assert.strictEqual(t.classifyGenerationError(new Error('random error')), 'unknown');
assert.strictEqual(t.classifyGenerationError(null), 'unknown', 'null error');
assert.strictEqual(t.classifyGenerationError('string error'), 'unknown', 'string error');
(async () => {
await testRunCliEdgeCases();
await testClaudeCodeArgs();
await testClaudeCodeStreamJsonOutput();
await testClaudeCodeStreamJsonResilience();
await testClaudeCodeVerboseRegressionCanary();
await testClaudeCodeArgsWithModel();
await testCodexArgs();
await testCodexArgsIgnoresClaudeDefaultModel();
await testCodexArgsMinimalContract();
await testCodexErrorPath();
console.log('cli tests passed');
})().catch((e) => {
console.error(e);
process.exit(1);
});