fix: harden CLI backend smoke checks

Change-Id: I7b4388095412b155c056c42661cdf481bd601e4c
This commit is contained in:
wujunchen 2026-05-09 11:49:23 +08:00
parent d00075779e
commit 714b6a368b
12 changed files with 317 additions and 46 deletions

View file

@ -1,7 +1,7 @@
{
"id": "parallel-reader",
"name": "Parallel Reader",
"version": "1.0.19",
"version": "1.0.20",
"minAppVersion": "1.8.7",
"description": "AI-powered split-view reading: source note on the left, LLM-generated summary cards on the right with scroll-sync highlighting.",
"author": "lancivez",

60
src/backend-test.ts Normal file
View file

@ -0,0 +1,60 @@
'use strict';
import { requestUrl } from 'obsidian';
import { resolveCliPath, runCli, summarizeViaClaudeCode, summarizeViaCodex } from './cli';
import type { RequestUrlFunction } from './provider-request';
import { testApiBackend } from './providers';
import { normalizeCliTimeoutMs } from './settings';
import type { PluginSettings } from './types';
const CLI_TEST_SYSTEM =
'Return only valid JSON with exactly one card: {"cards":[{"title":"CLI smoke","anchor":"parallel reader smoke anchor","gist":"ok","bullets":["ok"]}]}';
const CLI_TEST_USER = 'parallel reader smoke anchor';
const CLI_TEST_TIMEOUT_MS = 60000;
type SpawnImpl = Parameters<typeof runCli>[5];
export interface BackendTestDeps {
requestUrlImpl?: RequestUrlFunction;
spawnImpl?: SpawnImpl;
}
function cliSmokeSettings(settings: PluginSettings): PluginSettings {
return {
...settings,
cliTimeoutMs: Math.min(normalizeCliTimeoutMs(settings.cliTimeoutMs), CLI_TEST_TIMEOUT_MS),
minCards: 1,
maxCards: 1,
maxDocChars: 1000,
};
}
export async function testBackend(settings: PluginSettings, deps: BackendTestDeps = {}): Promise<string> {
if (settings.backend === 'claude-code') {
const cmd = resolveCliPath('claude', settings.cliPath);
const version = await runCli(cmd, ['--version'], '', 10000, undefined, deps.spawnImpl);
const cards = await summarizeViaClaudeCode(
CLI_TEST_SYSTEM,
CLI_TEST_USER,
cliSmokeSettings(settings),
undefined,
deps.spawnImpl,
);
return `claude @ ${cmd}\n${version.stdout.trim()}\nsmoke: ${cards.length} card`;
}
if (settings.backend === 'codex') {
const cmd = resolveCliPath('codex', settings.cliPath);
const version = await runCli(cmd, ['--version'], '', 10000, undefined, deps.spawnImpl);
const cards = await summarizeViaCodex(
CLI_TEST_SYSTEM,
CLI_TEST_USER,
cliSmokeSettings(settings),
undefined,
deps.spawnImpl,
);
return `codex @ ${cmd}\n${version.stdout.trim()}\nsmoke: ${cards.length} card`;
}
return testApiBackend(deps.requestUrlImpl || requestUrl, settings);
}

View file

@ -89,6 +89,7 @@ export class CliProcessError extends Error {
const DIAG_STDERR_TAIL_CHARS = 1500;
const DIAG_STDOUT_TAIL_CHARS = 1500;
const EMPTY_MCP_CONFIG = '{"mcpServers":{}}';
function tail(text: string, max: number): string {
if (text.length <= max) return text;
@ -108,6 +109,38 @@ function utf8ByteLength(text: string): number {
return Buffer.byteLength(text, 'utf8');
}
function claudeResultText(stdout: string): string {
const events: Array<Record<string, unknown>> = [];
try {
const parsed = JSON.parse(stdout);
if (Array.isArray(parsed)) {
events.push(...(parsed.filter((e) => e && typeof e === 'object') as Array<Record<string, unknown>>));
} else if (parsed && typeof parsed === 'object') {
events.push(parsed as Record<string, unknown>);
}
} catch {
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object') events.push(parsed as Record<string, unknown>);
} catch {
// Ignore non-JSON progress lines and keep scanning for result events.
}
}
}
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i];
if (event.type === 'result' && typeof event.result === 'string') return event.result;
}
const single = events[0];
if (single && typeof single.result === 'string') return single.result;
if (single && typeof single.content === 'string') return single.content;
return '';
}
export function runCli(
cmd: string,
args: string[],
@ -308,7 +341,7 @@ export function runCli(
try {
childStdin.write(stdinText);
childStdin.end();
} catch (_e) {
} catch {
// Child may have exited before stdin was written.
}
} else {
@ -332,38 +365,32 @@ export async function summarizeViaClaudeCode(
const args = [
'-p',
'--output-format',
'json',
'stream-json',
'--append-system-prompt',
system,
'--tools',
'',
'--disallowed-tools',
'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task',
'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task,LSP,NotebookEdit',
'--no-session-persistence',
'--disable-slash-commands',
'--no-chrome',
'--strict-mcp-config',
'--mcp-config',
EMPTY_MCP_CONFIG,
];
const claudeModel = (settings.model || '').trim();
if (claudeModel) args.push('--model', claudeModel);
const { stdout } = await runCli(cmd, args, user, settings.cliTimeoutMs, job, spawnImpl);
// --output-format json produces a JSON array of event objects.
// Find the "result" entry and extract its .result text field.
let resultText = '';
try {
const events = JSON.parse(stdout);
if (Array.isArray(events)) {
const resultEvent = events.find((e: Record<string, unknown>) => e.type === 'result');
if (resultEvent && typeof resultEvent.result === 'string') {
resultText = resultEvent.result;
}
} else if (events && typeof events === 'object') {
// Older CLI versions return a single object
resultText = events.result || events.content || '';
}
} catch (_) {
const resultText = claudeResultText(stdout);
if (!resultText && stdout.trim()) {
console.warn(
'[parallel-reader] claude CLI returned unexpected output. length=',
stdout.length,
'head=',
stdout.slice(0, 80),
);
throw new Error(translate(settings, 'errorClaudeCliBadJson', { length: String(stdout.length) }));
}
if (!resultText) {

View file

@ -143,7 +143,7 @@ export const STRINGS: Record<string, Record<string, string>> = {
settingCliPathDesc: '留空则自动探测常见位置Obsidian GUI 启动时不继承 shell PATH必要时填绝对路径',
settingCliPathPlaceholder: '例:/Users/you/bin/codex',
settingCliTimeoutName: 'CLI 超时 (ms)',
settingCliTimeoutDesc: 'CLI 调用的最大等待时间(毫秒),最小 1000默认 120000',
settingCliTimeoutDesc: 'CLI 调用的最大等待时间(毫秒),最小 1000默认 300000',
sectionQuickSetup: '快速配置',
sectionReadingOutput: '阅读输出',
sectionAdvancedConnection: '高级连接设置',
@ -183,8 +183,9 @@ export const STRINGS: Record<string, Record<string, string>> = {
settingCustomPromptPlaceholder: '留空使用内置 prompt',
settingTestBackendName: '测试当前后端',
settingTestBackendButton: '测试',
settingTestBackendButtonRunning: '测试中...',
settingTestBackendDescApi: '会发起一次最小 LLM 请求验证 API 设置',
settingTestBackendDescCli: '调用 `<cli> --version` 验证 spawn 能找到二进制',
settingTestBackendDescCli: '先调用 `<cli> --version`,再跑一次最小 JSON 生成,验证认证、输出解析和超时设置',
backendTestFailed: '✗ 后端测试失败:{error}',
settingExportFolderName: '导出文件夹',
settingExportFolderDesc: '对照笔记生成位置(相对 Vault 根)',
@ -360,7 +361,7 @@ export const STRINGS: Record<string, Record<string, string>> = {
'Leave blank to auto-detect common paths. Obsidian launched from the GUI may not inherit shell PATH.',
settingCliPathPlaceholder: 'Example: /Users/you/bin/codex',
settingCliTimeoutName: 'CLI timeout (ms)',
settingCliTimeoutDesc: 'Max wait time for CLI calls (ms). Minimum 1000, default 120000.',
settingCliTimeoutDesc: 'Max wait time for CLI calls (ms). Minimum 1000, default 300000.',
sectionQuickSetup: 'Quick setup',
sectionReadingOutput: 'Reading output',
sectionAdvancedConnection: 'Advanced connection',
@ -401,8 +402,10 @@ export const STRINGS: Record<string, Record<string, string>> = {
settingCustomPromptPlaceholder: 'Leave blank to use built-in prompt',
settingTestBackendName: 'Test current backend',
settingTestBackendButton: 'Test',
settingTestBackendButtonRunning: 'Testing...',
settingTestBackendDescApi: 'Sends a minimal LLM request to validate API settings.',
settingTestBackendDescCli: 'Runs `<cli> --version` to verify the binary can be spawned.',
settingTestBackendDescCli:
'Runs `<cli> --version`, then a minimal JSON generation to validate auth, output parsing, and timeout behavior.',
backendTestFailed: '✗ Backend test failed: {error}',
settingExportFolderName: 'Export folder',
settingExportFolderDesc: 'Parallel-note output location, relative to the Vault root.',

View file

@ -1,8 +1,7 @@
'use strict';
import { type App, Notice, type Plugin, PluginSettingTab, requestUrl, Setting } from 'obsidian';
import { resolveCliPath, runCli } from './cli';
import { testApiBackend } from './providers';
import { type App, Notice, type Plugin, PluginSettingTab, Setting } from 'obsidian';
import { testBackend } from './backend-test';
import {
API_AUTH_TYPES,
API_FORMATS,
@ -20,20 +19,6 @@ import {
} from './settings';
import type { PluginHost, PluginSettings } from './types';
async function testBackend(settings: PluginSettings) {
if (settings.backend === 'claude-code') {
const cmd = resolveCliPath('claude', settings.cliPath);
const { stdout } = await runCli(cmd, ['--version'], '', 10000);
return `claude @ ${cmd}\n${stdout.trim()}`;
}
if (settings.backend === 'codex') {
const cmd = resolveCliPath('codex', settings.cliPath);
const { stdout } = await runCli(cmd, ['--version'], '', 10000);
return `codex @ ${cmd}\n${stdout.trim()}`;
}
return testApiBackend(requestUrl, settings);
}
/** Detect whether the user has departed from preset defaults. If so we keep the
* Advanced connection section open so they can find what they configured. */
function shouldOpenAdvancedConnection(settings: PluginSettings): boolean {
@ -224,11 +209,16 @@ export class ParallelReaderSettingTab extends PluginSettingTab {
.setDesc(isCliBacked ? this.tr('settingTestBackendDescCli') : this.tr('settingTestBackendDescApi'))
.addButton((b) =>
b.setButtonText(this.tr('settingTestBackendButton')).onClick(async () => {
b.setDisabled(true);
b.setButtonText(this.tr('settingTestBackendButtonRunning'));
try {
const result = await testBackend(this.plugin.settings);
new Notice(`${result.slice(0, 180)}`, 8000);
} catch (e: unknown) {
new Notice(this.tr('backendTestFailed', { error: e instanceof Error ? e.message : String(e) }), 10000);
} finally {
b.setButtonText(this.tr('settingTestBackendButton'));
b.setDisabled(false);
}
}),
);

View file

@ -11,6 +11,7 @@ export const MAX_DOC_CHARS = 100000;
export const PROMPT_VERSION = 2;
export const CACHE_SCHEMA_VERSION = 2;
export const DEFAULT_MAX_CACHE_ENTRIES = 100;
export const DEFAULT_CLI_TIMEOUT_MS = 300000;
export const MIN_STREAMING_TIMEOUT_MS = 1000;
export const MIN_CLI_TIMEOUT_MS = 1000;
export const PROMPT_LANGUAGES = {
@ -44,7 +45,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
customSystemPrompt: '',
model: 'claude-sonnet-4-6',
exportFolder: 'Reading/Articles',
cliTimeoutMs: 120000,
cliTimeoutMs: DEFAULT_CLI_TIMEOUT_MS,
streaming: true,
streamingTimeoutMs: 120000,
};

View file

@ -2,6 +2,7 @@
export { default as ParallelReaderPlugin } from '../main';
export { findLineForAnchor } from './anchor';
export { testBackend } from './backend-test';
export {
batchProgressVars,
createBatchRunState,

144
tests/backend-test.test.js Normal file
View file

@ -0,0 +1,144 @@
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.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');
}
(async () => {
await testClaudeBackendRunsVersionAndSmoke();
await testCodexBackendRunsVersionAndSmoke();
await testApiBackendUsesInjectedRequestUrl();
console.log('backend-test tests passed');
})().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -38,6 +38,7 @@
"description": "Tests for provider protocols, CLI command contracts, exported test surfaces, and architecture invariants.",
"files": [
"architecture.test.js",
"backend-test.test.js",
"cli.test.js",
"direct-providers.test.js",
"providers.test.js",

View file

@ -258,13 +258,55 @@ async function testClaudeCodeArgs() {
// 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('--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('--strict-mcp-config'), 'should ignore user/project MCP servers');
// 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`).
@ -519,6 +561,7 @@ assert.strictEqual(t.classifyGenerationError('string error'), 'unknown', 'string
(async () => {
await testRunCliEdgeCases();
await testClaudeCodeArgs();
await testClaudeCodeStreamJsonOutput();
await testClaudeCodeArgsWithModel();
await testCodexArgs();
await testCodexArgsIgnoresClaudeDefaultModel();

View file

@ -103,11 +103,11 @@ assert.strictEqual(t.normalizeCardCount('15', 5), 15, 'numeric string accepted')
// normalizeCliTimeoutMs (mirrors normalizeStreamingTimeoutMs)
assert.strictEqual(t.normalizeCliTimeoutMs('60000'), 60000, 'cli timeout accepts numeric strings');
assert.strictEqual(t.normalizeCliTimeoutMs(999), 120000, 'cli timeout below minimum falls back to default');
assert.strictEqual(t.normalizeCliTimeoutMs('bad'), 120000, 'cli timeout rejects non-numeric values');
assert.strictEqual(t.normalizeCliTimeoutMs(999), 300000, 'cli timeout below minimum falls back to default');
assert.strictEqual(t.normalizeCliTimeoutMs('bad'), 300000, 'cli timeout rejects non-numeric values');
assert.strictEqual(
t.normalizeSettings({ ...baseSettings, cliTimeoutMs: 500 }).cliTimeoutMs,
120000,
300000,
'normalizeSettings protects invalid cli timeout values',
);

View file

@ -18,5 +18,6 @@
"1.0.16": "1.7.2",
"1.0.17": "1.7.2",
"1.0.18": "1.8.7",
"1.0.19": "1.8.7"
"1.0.19": "1.8.7",
"1.0.20": "1.8.7"
}