diff --git a/manifest.json b/manifest.json index 427a71d..5e0a6ad 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/src/backend-test.ts b/src/backend-test.ts new file mode 100644 index 0000000..c6b69d7 --- /dev/null +++ b/src/backend-test.ts @@ -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[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 { + 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); +} diff --git a/src/cli.ts b/src/cli.ts index f683a4f..a41acff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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> = []; + try { + const parsed = JSON.parse(stdout); + if (Array.isArray(parsed)) { + events.push(...(parsed.filter((e) => e && typeof e === 'object') as Array>)); + } else if (parsed && typeof parsed === 'object') { + events.push(parsed as Record); + } + } 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); + } 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) => 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) { diff --git a/src/i18n-strings.ts b/src/i18n-strings.ts index e902437..c76eab1 100644 --- a/src/i18n-strings.ts +++ b/src/i18n-strings.ts @@ -143,7 +143,7 @@ export const STRINGS: Record> = { 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> = { settingCustomPromptPlaceholder: '留空使用内置 prompt', settingTestBackendName: '测试当前后端', settingTestBackendButton: '测试', + settingTestBackendButtonRunning: '测试中...', settingTestBackendDescApi: '会发起一次最小 LLM 请求验证 API 设置', - settingTestBackendDescCli: '调用 ` --version` 验证 spawn 能找到二进制', + settingTestBackendDescCli: '先调用 ` --version`,再跑一次最小 JSON 生成,验证认证、输出解析和超时设置', backendTestFailed: '✗ 后端测试失败:{error}', settingExportFolderName: '导出文件夹', settingExportFolderDesc: '对照笔记生成位置(相对 Vault 根)', @@ -360,7 +361,7 @@ export const STRINGS: Record> = { '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> = { 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 ` --version` to verify the binary can be spawned.', + settingTestBackendDescCli: + 'Runs ` --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.', diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 01e7a2a..628cbe8 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -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); } }), ); diff --git a/src/settings.ts b/src/settings.ts index 23047e2..ae6a192 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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, }; diff --git a/src/test-exports.ts b/src/test-exports.ts index 6c73b7d..ba5eb8d 100644 --- a/src/test-exports.ts +++ b/src/test-exports.ts @@ -2,6 +2,7 @@ export { default as ParallelReaderPlugin } from '../main'; export { findLineForAnchor } from './anchor'; +export { testBackend } from './backend-test'; export { batchProgressVars, createBatchRunState, diff --git a/tests/backend-test.test.js b/tests/backend-test.test.js new file mode 100644 index 0000000..94a6482 --- /dev/null +++ b/tests/backend-test.test.js @@ -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); +}); diff --git a/tests/catalog.json b/tests/catalog.json index a6e6000..99f0225 100644 --- a/tests/catalog.json +++ b/tests/catalog.json @@ -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", diff --git a/tests/cli.test.js b/tests/cli.test.js index eb8a5f6..006d619 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -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(); diff --git a/tests/settings.test.js b/tests/settings.test.js index c2e9e79..99276e8 100644 --- a/tests/settings.test.js +++ b/tests/settings.test.js @@ -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', ); diff --git a/versions.json b/versions.json index f37353c..11f45de 100644 --- a/versions.json +++ b/versions.json @@ -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" }