From 199ab73188152f23a78d6780a5aec7d3c9d0db72 Mon Sep 17 00:00:00 2001 From: wujunchen Date: Mon, 27 Apr 2026 19:59:46 +0800 Subject: [PATCH] refactor: split main.test.js into focused test files Reduce main.test.js from 560 to 70 lines by keeping only architecture guards and export smoke tests. Moved provider error tests, Anthropic tool-use parsing, and cancellationNoticeKey tests to providers.test.js. Settings, cache, and other tests were already covered in their dedicated test files. Change-Id: I8f0f3c576dec7fcf5a4c6e0c2b6071601940349d --- tests/main.test.js | 581 ++++------------------------------------ tests/providers.test.js | 122 +++++++++ 2 files changed, 174 insertions(+), 529 deletions(-) diff --git a/tests/main.test.js b/tests/main.test.js index a5c390b..9c0efb4 100644 --- a/tests/main.test.js +++ b/tests/main.test.js @@ -1,9 +1,15 @@ -const crypto = require('crypto'); +/** + * Architecture guard tests — verify source code structure invariants + * that cannot be expressed via TypeScript types or lint rules. + */ const fs = require('fs'); const path = require('path'); const { assert, t } = require('./test-setup'); + const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.ts'), 'utf8'); const viewSource = fs.readFileSync(path.join(__dirname, '..', 'src', 'view.ts'), 'utf8'); + +// View lifecycle guards assert.ok(!/\basync\s+onOpen\s*\(/.test(viewSource), 'ParallelReaderView.onOpen should not be async without await'); assert.ok(!/\basync\s+onClose\s*\(\)\s*\{\s*\}/.test(viewSource), 'empty onClose should not be async'); assert.ok(/focusSummaryPane\s*\(\)/.test(viewSource), 'summary pane should expose a focus helper'); @@ -12,6 +18,8 @@ assert.ok( 'summary pane focus should not scroll the page', ); assert.ok(/moveActiveSection[\s\S]*focusSummaryPane/.test(viewSource), 'card navigation should focus the summary pane'); + +// Cache debounce guards assert.ok(/scheduleCacheSave\s*\(/.test(mainSource), 'cache touch should use a debounced cache save path'); assert.ok(/flushCacheSave\s*\(/.test(mainSource), 'pending cache touches should be flushable'); assert.ok(/onunload[\s\S]*flushCacheSave/.test(mainSource), 'plugin unload should flush pending cache touches'); @@ -25,537 +33,52 @@ assert.ok( !/handleFileRename[\s\S]*cacheManager\.cache\[/.test(mainSource), 'file rename should not mutate cache directly', ); + +// Module extraction guards assert.ok(!/function addIconButton/.test(mainSource), 'UI icon helper should live outside main.ts'); assert.ok(!/function addTextButton/.test(mainSource), 'UI text-button helper should live outside main.ts'); assert.ok(!/function copyToClipboard/.test(mainSource), 'clipboard helper should live outside main.ts'); -assert.strictEqual(typeof t.cardsToMarkdown, 'function'); -assert.strictEqual(typeof t.cancellationNoticeKey, 'function'); -assert.strictEqual(typeof t.summarizeDocument, 'function'); -assert.strictEqual(typeof t.addIconButton, 'function'); -assert.strictEqual(typeof t.addTextButton, 'function'); -assert.strictEqual(typeof t.copyToClipboard, 'function'); -assert.strictEqual(typeof t.resolveCliPath, 'function'); -assert.strictEqual(typeof t.runCli, 'function'); -assert.strictEqual(typeof t.buildPrompts, 'function'); -assert.strictEqual(typeof t.buildOpenAiChatBody, 'function'); -assert.strictEqual(typeof t.extractJson, 'function'); -assert.strictEqual(typeof t.findLineForAnchor, 'function'); -assert.strictEqual(typeof t.folderPathsForTarget, 'function'); -assert.strictEqual(typeof t.getApiBaseUrl, 'function'); -assert.strictEqual(typeof t.generationFingerprint, 'function'); -assert.strictEqual(typeof t.hasUnsafeBatchFolderSegments, 'function'); -assert.strictEqual(typeof t.CacheManager, 'function'); -assert.strictEqual(typeof t.GenerationJobManager, 'function'); -assert.strictEqual(typeof t.createBatchRunState, 'function'); -assert.strictEqual(typeof t.modelForApi, 'function'); -assert.strictEqual(typeof t.activeSectionLine, 'function'); -assert.strictEqual(typeof t.touchCacheEntry, 'function'); -assert.strictEqual(typeof t.nextCardIndex, 'function'); -assert.strictEqual(typeof t.pruneCacheEntries, 'function'); -assert.strictEqual(typeof t.removeCardAt, 'function'); -assert.strictEqual(typeof t.activeIndexAfterCardDelete, 'function'); -assert.strictEqual(typeof t.createRafThrottledHandler, 'function'); -assert.strictEqual(typeof t.visibleTopProbeY, 'function'); -assert.strictEqual(typeof t.serializeCacheFile, 'function'); -assert.strictEqual(typeof t.shouldConfirmRegenerate, 'function'); -assert.strictEqual(typeof t.translate, 'function'); -assert.strictEqual(typeof t.updateCardAt, 'function'); -assert.strictEqual(typeof t.validateBatchFolderInput, 'function'); -assert.strictEqual(typeof t.normalizeSettings, 'function'); -assert.strictEqual(typeof t.normalizeStreamingTimeoutMs, 'function'); -const baseSettings = { - backend: 'api', - apiProvider: 'openai', - apiFormat: 'openai-chat', - apiBaseUrl: 'https://api.openai.com/v1', - apiAuthType: 'bearer', - apiKey: 'test-key', - apiMaxTokens: 123, - model: 'openai/gpt-5.1', -}; - -assert.notStrictEqual( - t.generationFingerprint(baseSettings), - t.generationFingerprint({ ...baseSettings, model: 'openai/gpt-5.2' }), - 'cache fingerprint should change when model changes', -); -assert.notStrictEqual( - t.generationFingerprint(baseSettings), - t.generationFingerprint({ ...baseSettings, maxDocChars: 50000 }), - 'cache fingerprint should change when input truncation limit changes', -); -assert.notStrictEqual( - t.generationFingerprint(baseSettings), - t.generationFingerprint({ ...baseSettings, promptLanguage: 'en' }), - 'cache fingerprint should change when prompt language changes', -); -assert.notStrictEqual( - t.generationFingerprint(baseSettings), - t.generationFingerprint({ ...baseSettings, minCards: 3, maxCards: 8 }), - 'cache fingerprint should change when card count range changes', -); -assert.notStrictEqual( - t.generationFingerprint(baseSettings), - t.generationFingerprint({ ...baseSettings, customSystemPrompt: 'custom prompt' }), - 'cache fingerprint should change when custom prompt changes', -); -assert.strictEqual( - t.generationFingerprint(baseSettings), - t.generationFingerprint({ ...baseSettings, uiLanguage: 'en' }), - 'cache fingerprint should not change when UI language changes', -); -assert.strictEqual( - t.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }), - 'cancelRequestedApiInFlight', - 'API cancellation should explain that the in-flight network request cannot be aborted', -); -assert.strictEqual( - t.cancellationNoticeKey({ backend: 'claude-code' }, { phase: 'generating' }), - 'cancelRequested', - 'CLI cancellation can use the generic cancellation notice', -); -assert.strictEqual( - t.cancellationNoticeKey({ backend: 'api' }, { phase: 'reading' }), - 'cancelRequested', - 'API cancellation outside the request phase can use the generic notice', -); -assert.strictEqual(t.resolveCliPath('codex', ' /tmp/codex '), '/tmp/codex'); -assert.throws( - () => t.modelForApi({ ...baseSettings, model: '', uiLanguage: 'en' }), - /Model is not set/, - 'settings errors should respect English UI mode', -); -assert.throws( - () => - t.getApiBaseUrl({ - ...baseSettings, - apiProvider: 'custom-openai-compatible', - apiBaseUrl: '', - uiLanguage: 'en', - }), - /Custom provider requires an API Base URL/, - 'custom provider base URL errors should respect English UI mode', -); - -const contentHash = crypto.createHash('sha1').update('hello', 'utf8').digest('hex'); -assert.strictEqual( - t.cacheEntryMatches( - { - schemaVersion: t.CACHE_SCHEMA_VERSION, - contentHash, - settingsHash: t.generationFingerprint(baseSettings), - }, - 'hello', - baseSettings, - ), - true, - 'cache should match content, schema version, and generation fingerprint', -); -assert.strictEqual( - t.cacheEntryMatches( - { - schemaVersion: t.CACHE_SCHEMA_VERSION, - contentHash, - settingsHash: t.generationFingerprint({ ...baseSettings, model: 'openai/gpt-5.2' }), - }, - 'hello', - baseSettings, - ), - false, - 'cache should miss when generation settings change', -); -assert.strictEqual( - t.cacheEntryMatches( - { - schemaVersion: t.CACHE_SCHEMA_VERSION - 1, - contentHash, - settingsHash: t.generationFingerprint(baseSettings), - }, - 'hello', - baseSettings, - ), - false, - 'cache should miss when schema version changes', -); - -const cacheForPrune = { - 'old.md': { generatedAt: '2024-01-01T00:00:00.000Z' }, - 'new.md': { generatedAt: '2024-01-03T00:00:00.000Z' }, - 'touched.md': { - generatedAt: '2024-01-02T00:00:00.000Z', - lastAccessedAt: '2024-01-04T00:00:00.000Z', - }, -}; -assert.deepStrictEqual(t.pruneCacheEntries(cacheForPrune, 2), ['old.md']); -assert.deepStrictEqual(Object.keys(cacheForPrune).sort(), ['new.md', 'touched.md']); - -assert.strictEqual( - t.findLineForAnchor('intro\nAlpha beta\nGamma\tDelta\nlast', 'Alpha beta Gamma Delta'), - 1, - 'whitespace-normalized anchor fallback should map back to the original source line', -); - -const englishPrompt = t.buildPrompts('Hello world', { - ...baseSettings, - promptLanguage: 'en', - minCards: 2, - maxCards: 4, -}); -assert.ok(englishPrompt.system.includes('2-4')); -assert.ok(englishPrompt.system.includes('Write title, gist, and bullets in English.')); -assert.ok(englishPrompt.system.includes('You are a long-form reading summary assistant.')); -assert.ok(!englishPrompt.system.includes('你是一个长文阅读摘要助手')); -assert.ok(englishPrompt.user.startsWith('Source document:')); - -const customPrompt = t.buildPrompts('Hello world', { - ...baseSettings, - promptLanguage: 'auto', - minCards: 1, - maxCards: 2, - customSystemPrompt: 'Make {minCards}-{maxCards} cards. {languageInstruction}', -}); -assert.ok(customPrompt.system.includes('Make 1-2 cards.')); -assert.ok(customPrompt.system.includes('不可覆盖的输出契约')); -assert.ok(customPrompt.system.includes('JSON shape')); - -assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cmdOpenView'), 'Open Parallel Reader pane'); -assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'cmdOpenView'), '打开对照笔记面板'); -assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 3 }), 'Cleared 3 cache entries'); -assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cmdCardNext'), 'Focus next summary card'); - -assert.strictEqual(t.nextCardIndex(-1, 3, 1), 0); -assert.strictEqual(t.nextCardIndex(-1, 3, -1), 2); -assert.strictEqual(t.nextCardIndex(0, 3, 1), 1); -assert.strictEqual(t.nextCardIndex(2, 3, 1), 2); -assert.strictEqual(t.nextCardIndex(0, 3, -1), 0); -assert.strictEqual(t.nextCardIndex(0, 0, 1), -1); -assert.strictEqual(t.activeSectionLine([{ startLine: 3 }], 0), 3); -assert.strictEqual(t.activeSectionLine([{ startLine: -1 }], 0), -1); -assert.strictEqual(t.activeSectionLine([{ startLine: 3 }], 1), -1); -const scheduledFrames = []; -let throttledCalls = 0; -const throttled = t.createRafThrottledHandler( - () => { - throttledCalls += 1; - }, - (cb) => { - scheduledFrames.push(cb); - return scheduledFrames.length; - }, -); -throttled(); -throttled(); -throttled(); -assert.strictEqual(scheduledFrames.length, 1, 'scroll handler should be scheduled at most once per frame'); -assert.strictEqual(throttledCalls, 0, 'throttled handler should not run synchronously'); -scheduledFrames.shift()(0); -assert.strictEqual(throttledCalls, 1); -throttled(); -assert.strictEqual(scheduledFrames.length, 1, 'scroll handler should be schedulable after the frame runs'); -assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 300 }), 130); -assert.strictEqual(t.visibleTopProbeY({ top: 100, height: 1200 }), 180); -const cardList = [{ title: 'A' }, { title: 'B' }, { title: 'C' }]; -assert.deepStrictEqual(t.removeCardAt(cardList, 1), [{ title: 'A' }, { title: 'C' }]); -assert.deepStrictEqual(t.removeCardAt(cardList, -1), cardList); -assert.deepStrictEqual(t.removeCardAt(cardList, 3), cardList); -assert.notStrictEqual(t.removeCardAt(cardList, 3), cardList); -assert.strictEqual(t.activeIndexAfterCardDelete(1, 3, 1), 1, 'deleting active card should select the next card'); -assert.strictEqual( - t.activeIndexAfterCardDelete(2, 3, 2), - 1, - 'deleting the last active card should select the previous card', -); -assert.strictEqual( - t.activeIndexAfterCardDelete(1, 3, 2), - 1, - 'deleting before active card should shift active index left', -); -assert.strictEqual(t.activeIndexAfterCardDelete(2, 3, 0), 0, 'deleting after active card should keep active index'); -assert.deepStrictEqual(t.updateCardAt(cardList, 1, { title: 'B2', gist: 'G', bullets: ['x'] }), [ - { title: 'A' }, - { title: 'B2', gist: 'G', bullets: ['x'] }, - { title: 'C' }, -]); -assert.deepStrictEqual(t.updateCardAt(cardList, -1, { title: 'X' }), cardList); -assert.deepStrictEqual(t.updateCardAt(cardList, 3, { title: 'X' }), cardList); -assert.notStrictEqual(t.updateCardAt(cardList, 3, { title: 'X' }), cardList); -assert.deepStrictEqual(t.folderPathsForTarget('Reading/Articles'), ['Reading', 'Reading/Articles']); -assert.deepStrictEqual(t.folderPathsForTarget('/Reading//Articles/'), ['Reading', 'Reading/Articles']); -assert.deepStrictEqual(t.folderPathsForTarget('Reading'), ['Reading']); -assert.deepStrictEqual(t.folderPathsForTarget(''), []); -const untouchedCacheEntry = { generatedAt: '2024-01-01T00:00:00.000Z' }; -assert.strictEqual(t.touchCacheEntry(null), null); -const touchedEntry = t.touchCacheEntry(untouchedCacheEntry, '2024-01-05T00:00:00.000Z'); -assert.notStrictEqual(touchedEntry, untouchedCacheEntry, 'touchCacheEntry returns new object'); -assert.strictEqual(touchedEntry.lastAccessedAt, '2024-01-05T00:00:00.000Z', 'touched entry has updated timestamp'); -assert.strictEqual(untouchedCacheEntry.lastAccessedAt, undefined, 'original entry not mutated'); -assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-05T00:00:00.000Z' }, true), true); -assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-05T00:00:00.000Z' }, false), false); -assert.strictEqual(t.shouldConfirmRegenerate({ generatedAt: '2024-01-01T00:00:00.000Z' }, true), false); -assert.strictEqual(t.shouldConfirmRegenerate(null, true), false); -const serializedCache = t.serializeCacheFile({ - 'note.md': { generatedAt: '2024-01-01T00:00:00.000Z', cards: [{ title: 'A' }] }, -}); -assert.strictEqual(serializedCache.includes('\n'), false, 'cache.json should be compact'); -assert.deepStrictEqual(JSON.parse(serializedCache).entries['note.md'].cards, [{ title: 'A' }]); - -const noisyJson = - '说明文字 {"cards":[{"title":"A","anchor":"保留 { 花括号 } 字符","gist":"G","bullets":["B"]}]} trailing'; -const extracted = t.extractJson(noisyJson); -assert.deepStrictEqual(JSON.parse(extracted).cards[0].bullets, ['B']); - -const openAiChatBody = t.buildOpenAiChatBody('system JSON', 'user', baseSettings); -assert.strictEqual(openAiChatBody.max_completion_tokens, 123); -assert.strictEqual(openAiChatBody.max_tokens, undefined); -assert.strictEqual(openAiChatBody.response_format.type, 'json_schema'); - -const compatChatBody = t.buildOpenAiChatBody('system JSON', 'user', { - ...baseSettings, - apiProvider: 'openrouter', - model: 'openrouter/anthropic/claude-sonnet-4-5', -}); -assert.strictEqual(compatChatBody.max_tokens, 123); -assert.strictEqual(compatChatBody.max_completion_tokens, undefined); -assert.strictEqual(compatChatBody.response_format.type, 'json_schema'); - -const responsesBody = t.buildOpenAiResponsesBody('system JSON', 'user', baseSettings); -assert.strictEqual(responsesBody.text.format.type, 'json_schema'); - -const geminiBody = t.buildGeminiBody('system JSON', 'user', { - ...baseSettings, - apiProvider: 'google', - apiFormat: 'google-generative-ai', -}); -assert.strictEqual(geminiBody.generationConfig.responseMimeType, 'application/json'); -assert.strictEqual(geminiBody.generationConfig.responseJsonSchema.type, 'object'); - -const anthropicBody = t.buildAnthropicMessagesBody('system JSON', 'user', { - ...baseSettings, - apiProvider: 'anthropic', - apiFormat: 'anthropic-messages', - model: 'anthropic/claude-sonnet-4-6', -}); -assert.strictEqual(anthropicBody.tools[0].name, 'record_parallel_reader_cards'); -assert.strictEqual(anthropicBody.tool_choice.name, 'record_parallel_reader_cards'); -assert.strictEqual( - t.buildAnthropicMessagesBody('system JSON', 'user', baseSettings, { structured: false }).tools, - undefined, -); - -const markdown = t.cardsToMarkdown('Example', [ - { - title: '第一段', - anchor: '原文引用', - gist: '核心摘要', - bullets: ['要点 A', '要点 B'], - }, -]); -assert.ok(markdown.includes('# Example')); -assert.ok(markdown.includes('## 第一段')); -assert.ok(markdown.includes('- 要点 A')); - -async function testOpenAiStructuredFallback() { - const calls = []; - const requestUrlImpl = async (req) => { - const body = JSON.parse(req.body); - calls.push(body); - if (calls.length === 1) { - return { status: 400, text: 'unsupported response_format json_schema' }; - } - return { - status: 200, - json: { - choices: [ - { - message: { - content: '{"cards":[{"title":"T","anchor":"A","gist":"G","bullets":["B"]}]}', - }, - }, - ], - }, - }; - }; - - const originalWarn = console.warn; - console.warn = () => {}; - let cards; - try { - cards = await t.summarizeViaApi(requestUrlImpl, 'system JSON', 'user', baseSettings); - } finally { - console.warn = originalWarn; - } - assert.strictEqual(calls.length, 2); - assert.strictEqual(calls[0].response_format.type, 'json_schema'); - assert.strictEqual(calls[1].response_format, undefined); - assert.deepStrictEqual(cards, [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }]); +// Export surface smoke test +const expectedExports = [ + 'cardsToMarkdown', + 'cancellationNoticeKey', + 'summarizeDocument', + 'addIconButton', + 'addTextButton', + 'copyToClipboard', + 'resolveCliPath', + 'runCli', + 'buildPrompts', + 'buildOpenAiChatBody', + 'extractJson', + 'findLineForAnchor', + 'folderPathsForTarget', + 'getApiBaseUrl', + 'generationFingerprint', + 'hasUnsafeBatchFolderSegments', + 'CacheManager', + 'GenerationJobManager', + 'createBatchRunState', + 'modelForApi', + 'activeSectionLine', + 'touchCacheEntry', + 'nextCardIndex', + 'pruneCacheEntries', + 'removeCardAt', + 'activeIndexAfterCardDelete', + 'createRafThrottledHandler', + 'visibleTopProbeY', + 'serializeCacheFile', + 'shouldConfirmRegenerate', + 'translate', + 'updateCardAt', + 'validateBatchFolderInput', + 'normalizeSettings', + 'normalizeStreamingTimeoutMs', +]; +for (const name of expectedExports) { + assert.strictEqual(typeof t[name], 'function', `test-exports should include ${name}`); } -async function testProviderMissingApiKeyUsesEnglishUi() { - await assert.rejects( - () => - t.summarizeViaApi( - async () => { - throw new Error('request should not be sent'); - }, - 'system JSON', - 'user', - { - ...baseSettings, - apiKey: '', - apiKeyEnvVar: '', - uiLanguage: 'en', - }, - ), - /API key is not set/, - ); -} - -async function testProviderHeaderJsonErrorUsesEnglishUi() { - await assert.rejects( - () => - t.summarizeViaApi( - async () => { - throw new Error('request should not be sent'); - }, - 'system JSON', - 'user', - { - ...baseSettings, - apiHeaders: '{"x":', - uiLanguage: 'en', - }, - ), - /Custom headers JSON parse failed/, - ); -} - -async function testProviderResponseNonJsonUsesEnglishUi() { - await assert.rejects( - () => - t.summarizeViaApi( - async () => ({ - status: 200, - text: 'not json', - }), - 'system JSON', - 'user', - { - ...baseSettings, - uiLanguage: 'en', - }, - ), - /OpenAI-compatible Chat returned non-JSON/, - ); -} - -async function testProviderRequestFailureUsesEnglishUi() { - await assert.rejects( - () => - t.summarizeViaApi( - async () => { - throw new Error('network down'); - }, - 'system JSON', - 'user', - { - ...baseSettings, - uiLanguage: 'en', - }, - ), - /OpenAI-compatible Chat request failed: network down/, - ); -} - -async function testProviderApiStatusErrorUsesEnglishUi() { - await assert.rejects( - () => - t.summarizeViaApi( - async () => ({ - status: 500, - text: 'upstream exploded', - }), - 'system JSON', - 'user', - { - ...baseSettings, - uiLanguage: 'en', - }, - ), - /OpenAI-compatible Chat API returned HTTP 500: upstream exploded/, - ); -} - -async function testSchemaNonJsonErrorUsesEnglishUi() { - await assert.rejects( - () => - t.summarizeViaApi( - async () => ({ - status: 200, - json: { - choices: [ - { - message: { - content: 'not json', - }, - }, - ], - }, - }), - 'system JSON', - 'user', - { - ...baseSettings, - uiLanguage: 'en', - }, - ), - /LLM returned non-JSON/, - ); -} - -async function testAnthropicToolUseParsing() { - const requestUrlImpl = async (req) => { - const body = JSON.parse(req.body); - assert.strictEqual(body.tools[0].name, 'record_parallel_reader_cards'); - assert.strictEqual(body.tool_choice.name, 'record_parallel_reader_cards'); - return { - status: 200, - json: { - content: [ - { - type: 'tool_use', - name: 'record_parallel_reader_cards', - input: { - cards: [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }], - }, - }, - ], - }, - }; - }; - - const cards = await t.summarizeViaApi(requestUrlImpl, 'system JSON', 'user', { - ...baseSettings, - apiProvider: 'anthropic', - apiFormat: 'anthropic-messages', - apiBaseUrl: 'https://api.anthropic.com/v1', - apiAuthType: 'x-api-key', - model: 'anthropic/claude-sonnet-4-6', - }); - assert.deepStrictEqual(cards, [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }]); -} - -(async () => { - await testOpenAiStructuredFallback(); - await testProviderMissingApiKeyUsesEnglishUi(); - await testProviderHeaderJsonErrorUsesEnglishUi(); - await testProviderResponseNonJsonUsesEnglishUi(); - await testProviderRequestFailureUsesEnglishUi(); - await testProviderApiStatusErrorUsesEnglishUi(); - await testSchemaNonJsonErrorUsesEnglishUi(); - await testAnthropicToolUseParsing(); - console.log('tests passed'); -})().catch((e) => { - console.error(e); - process.exit(1); -}); +console.log('tests passed'); diff --git a/tests/providers.test.js b/tests/providers.test.js index 60ce862..e39da9d 100644 --- a/tests/providers.test.js +++ b/tests/providers.test.js @@ -221,10 +221,132 @@ async function testStructuredOutputNonRetryableError() { } } +async function testProviderMissingApiKey() { + await assert.rejects( + () => + t.summarizeViaApi( + async () => { + throw new Error('request should not be sent'); + }, + 'system JSON', + 'user', + { ...baseSettings, apiKey: '', apiKeyEnvVar: '', uiLanguage: 'en' }, + ), + /API key is not set/, + ); +} + +async function testProviderHeaderJsonError() { + await assert.rejects( + () => + t.summarizeViaApi( + async () => { + throw new Error('request should not be sent'); + }, + 'system JSON', + 'user', + { ...baseSettings, apiHeaders: '{"x":', uiLanguage: 'en' }, + ), + /Custom headers JSON parse failed/, + ); +} + +async function testProviderResponseNonJson() { + await assert.rejects( + () => + t.summarizeViaApi(async () => ({ status: 200, text: 'not json' }), 'system JSON', 'user', { + ...baseSettings, + uiLanguage: 'en', + }), + /OpenAI-compatible Chat returned non-JSON/, + ); +} + +async function testProviderRequestFailure() { + await assert.rejects( + () => + t.summarizeViaApi( + async () => { + throw new Error('network down'); + }, + 'system JSON', + 'user', + { ...baseSettings, uiLanguage: 'en' }, + ), + /OpenAI-compatible Chat request failed: network down/, + ); +} + +async function testProviderApiStatusError() { + await assert.rejects( + () => + t.summarizeViaApi(async () => ({ status: 500, text: 'upstream exploded' }), 'system JSON', 'user', { + ...baseSettings, + uiLanguage: 'en', + }), + /OpenAI-compatible Chat API returned HTTP 500: upstream exploded/, + ); +} + +async function testAnthropicToolUseParsing() { + const cards = await t.summarizeViaApi( + async (req) => { + const body = JSON.parse(req.body); + assert.strictEqual(body.tools[0].name, 'record_parallel_reader_cards'); + return { + status: 200, + json: { + content: [ + { + type: 'tool_use', + name: 'record_parallel_reader_cards', + input: { cards: [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }] }, + }, + ], + }, + }; + }, + 'system JSON', + 'user', + { + ...baseSettings, + apiProvider: 'anthropic', + apiFormat: 'anthropic-messages', + apiBaseUrl: 'https://api.anthropic.com/v1', + apiAuthType: 'x-api-key', + model: 'anthropic/claude-sonnet-4-6', + }, + ); + assert.deepStrictEqual(cards, [{ title: 'A', anchor: 'quote', gist: 'gist', bullets: ['one'] }]); +} + +// cancellationNoticeKey +assert.strictEqual( + t.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }), + 'cancelRequestedApiInFlight', + 'API cancellation should explain that the in-flight network request cannot be aborted', +); +assert.strictEqual( + t.cancellationNoticeKey({ backend: 'claude-code' }, { phase: 'generating' }), + 'cancelRequested', + 'CLI cancellation can use the generic cancellation notice', +); +assert.strictEqual( + t.cancellationNoticeKey({ backend: 'api' }, { phase: 'reading' }), + 'cancelRequested', + 'API cancellation outside the request phase can use the generic notice', +); + (async () => { await testSummarizeDocumentAnchorSorting(); await testStructuredOutputFallback(); await testStructuredOutputNonRetryableError(); + await testProviderMissingApiKey(); + await testProviderHeaderJsonError(); + await testProviderResponseNonJson(); + await testProviderRequestFailure(); + await testProviderApiStatusError(); + await testAnthropicToolUseParsing(); console.log('providers tests passed'); })().catch((e) => { console.error(e);