/** * All tests. Loaded as an ES module so it can import the shared modules * and background modules directly. */ import { isValidOllamaHost, isValidTemperature, isValidContextLength, isValidSeedMode, isValidAdvancedValue, isValidStopSequences, defaultSettings, sanitizeSettings, buildOptions, toApiThinking } from '../src/shared/settings-schema.js'; import { buildSystemPrompt, BUILTIN_SYSTEM_PROMPT } from '../src/shared/prompt.js'; import { isValidTranslateRequest, isValidCancelRequest, createRequestId } from '../src/shared/messages.js'; import { MAX_SELECTION_LENGTH, MAX_USER_SYSTEM_PROMPT_LENGTH, CONTEXT_PRESETS, PORT_NAME, ERROR_CODES, CONTENT_TO_BG, BG_TO_CONTENT, RUNTIME_MESSAGES } from '../src/shared/constants.js'; import { listModels, chatStream, toExtensionError } from '../src/background/ollama-client.js'; import { TranslationSession } from '../src/background/translation-session.js'; import { loadSettings, saveSettings } from '../src/background/settings-service.js'; import { handleConnection, registerRuntimeMessageHandler } from '../src/background/message-router.js'; import { contentConstantsForSyncCheck } from './content-constants.js'; const { describe, it, assertEqual, assertTrue, assertFalse, assertCloseTo, assertThrowsAsync, run } = window.TestRunner; const { ndjsonResponse, errorResponse, fakeStorage, fakePort, fakeRuntime } = window.TestMocks; // --------------------------------------------------------------------- // Unit: constants sync (content script mirror) // --------------------------------------------------------------------- describe('constants sync (content mirror)', () => { it('PORT_NAME matches content script', () => { assertEqual(contentConstantsForSyncCheck.PORT_NAME, PORT_NAME); }); it('MAX_SELECTION_LENGTH matches content script', () => { assertEqual(contentConstantsForSyncCheck.MAX_SELECTION_LENGTH, MAX_SELECTION_LENGTH); }); it('ERROR_CODES match content script', () => { assertEqual(contentConstantsForSyncCheck.ERROR_CODES, ERROR_CODES); }); it('message types match content script', () => { assertEqual(contentConstantsForSyncCheck.CONTENT_TO_BG, CONTENT_TO_BG); assertEqual(contentConstantsForSyncCheck.BG_TO_CONTENT, BG_TO_CONTENT); }); }); // --------------------------------------------------------------------- // Unit: host validation // --------------------------------------------------------------------- describe('ollama host validation', () => { it('accepts http://localhost:11434', () => { assertTrue(isValidOllamaHost('http://localhost:11434')); }); it('accepts http://127.0.0.1:11434', () => { assertTrue(isValidOllamaHost('http://127.0.0.1:11434')); }); it('accepts arbitrary port', () => { assertTrue(isValidOllamaHost('http://localhost:8080')); }); it('accepts no port (default 80)', () => { assertTrue(isValidOllamaHost('http://localhost')); }); it('rejects external host', () => { assertFalse(isValidOllamaHost('http://example.com:11434')); }); it('rejects LAN IP', () => { assertFalse(isValidOllamaHost('http://192.168.1.10:11434')); }); it('rejects https', () => { assertFalse(isValidOllamaHost('https://localhost:11434')); }); it('rejects non-URL', () => { assertFalse(isValidOllamaHost('localhost:11434')); assertFalse(isValidOllamaHost('')); assertFalse(isValidOllamaHost(null)); }); it('rejects path/query/hash/credentials', () => { assertFalse(isValidOllamaHost('http://localhost:11434/api')); assertFalse(isValidOllamaHost('http://localhost:11434/?x=1')); assertFalse(isValidOllamaHost('http://localhost:11434/#a')); assertFalse(isValidOllamaHost('http://user:pass@localhost:11434')); }); }); // --------------------------------------------------------------------- // Unit: settings schema // --------------------------------------------------------------------- describe('settings schema defaults', () => { it('produces expected defaults', () => { const s = defaultSettings(); assertEqual(s.enabled, true); assertEqual(s.ollamaHost, 'http://localhost:11434'); assertEqual(s.model, ''); assertEqual(s.targetLanguage, 'ja'); assertEqual(s.thinking, 'off'); assertEqual(s.temperature, 0.2); assertEqual(s.contextLength, 16384); assertEqual(s.seedMode, 'random'); assertEqual(s.seedValue, null); assertEqual(s.userSystemPrompt, ''); assertEqual(s.advanced.stop, []); }); it('sanitize falls back for garbage input', () => { const s = sanitizeSettings('not an object'); assertEqual(s, defaultSettings()); }); it('sanitize drops invalid fields and keeps valid ones', () => { const s = sanitizeSettings({ enabled: false, ollamaHost: 'http://evil.example:11434', model: 'llama3', temperature: 99, contextLength: 12345, seedMode: 'sometimes', targetLanguage: 'xx' }); assertEqual(s.enabled, false); assertEqual(s.ollamaHost, 'http://localhost:11434'); // invalid host -> default assertEqual(s.model, 'llama3'); assertEqual(s.temperature, 0.2); assertEqual(s.contextLength, 16384); assertEqual(s.seedMode, 'random'); assertEqual(s.targetLanguage, 'ja'); }); it('user prompt length limit enforced', () => { const long = 'a'.repeat(MAX_USER_SYSTEM_PROMPT_LENGTH + 1); const s = sanitizeSettings({ userSystemPrompt: long }); assertEqual(s.userSystemPrompt, ''); const ok = sanitizeSettings({ userSystemPrompt: 'a'.repeat(MAX_USER_SYSTEM_PROMPT_LENGTH) }); assertEqual(ok.userSystemPrompt.length, MAX_USER_SYSTEM_PROMPT_LENGTH); }); }); describe('temperature validation', () => { it('accepts 0.0 to 2.0 in 0.1 steps', () => { assertTrue(isValidTemperature(0)); assertTrue(isValidTemperature(0.2)); assertTrue(isValidTemperature(2)); }); it('rejects out of range', () => { assertFalse(isValidTemperature(-0.1)); assertFalse(isValidTemperature(2.1)); }); it('rejects off-step values', () => { assertFalse(isValidTemperature(0.15)); assertFalse(isValidTemperature(1.111)); }); it('sanitize rounds nothing: off-step falls back to default', () => { const s = sanitizeSettings({ temperature: 0.15 }); assertEqual(s.temperature, 0.2); }); }); describe('context length validation', () => { it('accepts only presets', () => { for (const value of CONTEXT_PRESETS) assertTrue(isValidContextLength(value)); assertFalse(isValidContextLength(2048)); assertFalse(isValidContextLength(16383)); }); }); describe('seed validation', () => { it('accepts random/fixed', () => { assertTrue(isValidSeedMode('random')); assertTrue(isValidSeedMode('fixed')); assertFalse(isValidSeedMode('always')); }); it('fixed with integer seed passes through', () => { const s = sanitizeSettings({ seedMode: 'fixed', seedValue: 42 }); assertEqual(s.seedMode, 'fixed'); assertEqual(s.seedValue, 42); }); it('non-integer seed is dropped', () => { const s = sanitizeSettings({ seedMode: 'fixed', seedValue: 1.5 }); assertEqual(s.seedValue, null); }); }); describe('advanced options validation', () => { it('null is allowed (unset)', () => { assertTrue(isValidAdvancedValue('topK', null)); assertTrue(isValidAdvancedValue('topP', null)); }); it('int fields require integers', () => { assertTrue(isValidAdvancedValue('topK', 40)); assertFalse(isValidAdvancedValue('topK', 0.5)); assertTrue(isValidAdvancedValue('numPredict', -1)); assertTrue(isValidAdvancedValue('repeatLastN', -1)); }); it('float fields accept finite numbers', () => { assertTrue(isValidAdvancedValue('topP', 0.9)); assertFalse(isValidAdvancedValue('topP', 'x')); }); it('stop sequences: array of strings only', () => { assertTrue(isValidStopSequences([''])); assertTrue(isValidStopSequences([])); assertTrue(isValidStopSequences(null)); assertFalse(isValidStopSequences('not array')); assertFalse(isValidStopSequences([1])); assertFalse(isValidStopSequences(new Array(17).fill('x'))); }); }); describe('buildOptions', () => { it('emits temperature and num_ctx always', () => { const s = defaultSettings(); const o = buildOptions(s); assertEqual(o.temperature, 0.2); assertEqual(o.num_ctx, 16384); }); it('omits unset advanced options', () => { const o = buildOptions(defaultSettings()); assertFalse('top_k' in o); assertFalse('seed' in o); assertFalse('stop' in o); assertFalse('num_keep' in o); }); it('includes fixed seed only in fixed mode', () => { const fixed = sanitizeSettings({ seedMode: 'fixed', seedValue: 42 }); assertEqual(buildOptions(fixed).seed, 42); const random = sanitizeSettings({ seedMode: 'random', seedValue: 42 }); assertFalse('seed' in buildOptions(random)); }); it('includes set advanced options with API names', () => { const s = sanitizeSettings({ advanced: { topK: 40, topP: 0.9, numPredict: -1, stop: [''] } }); const o = buildOptions(s); assertEqual(o.top_k, 40); assertEqual(o.top_p, 0.9); assertEqual(o.num_predict, -1); assertEqual(o.stop, ['']); }); it('never includes tfs_z or mirostat fields', () => { const o = buildOptions(sanitizeSettings({ advanced: { topK: 5 } })); assertFalse('tfs_z' in o); assertFalse('mirostat' in o); assertFalse('mirostat_tau' in o); assertFalse('mirostat_eta' in o); }); }); describe('toApiThinking', () => { it('off -> undefined (omitted)', () => { assertEqual(toApiThinking({ thinking: 'off' }), undefined); }); it('on -> true', () => { assertEqual(toApiThinking({ thinking: 'on' }), true); }); it('low/medium/high -> string', () => { assertEqual(toApiThinking({ thinking: 'low' }), 'low'); assertEqual(toApiThinking({ thinking: 'high' }), 'high'); }); }); // --------------------------------------------------------------------- // Unit: system prompt // --------------------------------------------------------------------- describe('system prompt builder', () => { it('starts with the fixed prompt', () => { const prompt = buildSystemPrompt(defaultSettings()); assertTrue(prompt.startsWith(BUILTIN_SYSTEM_PROMPT)); }); it('contains target language', () => { const prompt = buildSystemPrompt({ ...defaultSettings(), targetLanguage: 'en' }); assertTrue(prompt.includes('Target language: English')); }); it('appends user prompt after fixed prompt', () => { const prompt = buildSystemPrompt({ ...defaultSettings(), userSystemPrompt: 'カジュアルに' }); assertTrue(prompt.includes('[User translation preferences]')); assertTrue(prompt.indexOf(BUILTIN_SYSTEM_PROMPT) < prompt.indexOf('カジュアルに')); }); it('empty user prompt -> no user section', () => { const prompt = buildSystemPrompt(defaultSettings()); assertFalse(prompt.includes('[User translation preferences]')); }); }); // --------------------------------------------------------------------- // Unit: message validation // --------------------------------------------------------------------- describe('message validation', () => { it('valid translate request passes', () => { assertTrue( isValidTranslateRequest({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'abc', text: 'hello' }) ); }); it('rejects wrong type / missing fields', () => { assertFalse(isValidTranslateRequest(null)); assertFalse(isValidTranslateRequest('string')); assertFalse(isValidTranslateRequest({ type: 'nope', requestId: 'a', text: 'x' })); assertFalse(isValidTranslateRequest({ type: CONTENT_TO_BG.TRANSLATE, requestId: '', text: 'x' })); assertFalse(isValidTranslateRequest({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'a' })); assertFalse(isValidTranslateRequest({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'a', text: 42 })); assertFalse(isValidTranslateRequest({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'a', text: '' })); }); it('rejects oversized text', () => { const big = 'a'.repeat(MAX_SELECTION_LENGTH + 1); assertFalse(isValidTranslateRequest({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'a', text: big })); }); it('accepts text exactly at limit', () => { const atLimit = 'a'.repeat(MAX_SELECTION_LENGTH); assertTrue(isValidTranslateRequest({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'a', text: atLimit })); }); it('valid cancel request', () => { assertTrue(isValidCancelRequest({ type: CONTENT_TO_BG.CANCEL, requestId: 'abc' })); assertFalse(isValidCancelRequest({ type: CONTENT_TO_BG.CANCEL, requestId: '' })); assertFalse(isValidCancelRequest({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'a', text: 'x' })); }); it('requestId generation returns usable ids', () => { const id = createRequestId(); assertTrue(typeof id === 'string' && id.length > 0); assertEqual(createRequestId() === id, false); }); }); // --------------------------------------------------------------------- // Integration: Ollama client // --------------------------------------------------------------------- const realFetch = globalThis.fetch; describe('ollama client: listModels', () => { it('returns model names', async () => { globalThis.fetch = async () => new Response(JSON.stringify({ models: [{ name: 'llama3' }, { name: 'qwen2.5' }] }), { status: 200 }); try { const models = await listModels('http://localhost:11434'); assertEqual(models, ['llama3', 'qwen2.5']); } finally { globalThis.fetch = realFetch; } }); it('maps 404 to MODEL_NOT_FOUND', async () => { globalThis.fetch = async () => errorResponse(404); try { await assertThrowsAsync(() => listModels('http://localhost:11434')); try { await listModels('http://localhost:11434'); } catch (e) { assertEqual(e.code, ERROR_CODES.MODEL_NOT_FOUND); } } finally { globalThis.fetch = realFetch; } }); it('rejects disallowed host before fetch', async () => { let called = false; globalThis.fetch = async () => { called = true; return new Response('{}', { status: 200 }); }; try { await assertThrowsAsync(() => listModels('http://example.com:11434')); assertEqual(called, false); } finally { globalThis.fetch = realFetch; } }); it('maps network failure to OLLAMA_UNAVAILABLE', async () => { globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); }; try { try { await listModels('http://localhost:11434'); } catch (e) { assertEqual(e.code, ERROR_CODES.OLLAMA_UNAVAILABLE); } } finally { globalThis.fetch = realFetch; } }); }); describe('ollama client: chatStream', () => { it('streams content chunks across awkward byte boundaries', async () => { const lines = [ JSON.stringify({ message: { content: 'こんにちは' } }), JSON.stringify({ message: { content: '、世界' } }), JSON.stringify({ done: true }) ]; globalThis.fetch = async () => ndjsonResponse(lines, [3, 7, 1, 11]); try { const chunks = []; await chatStream({ host: 'http://localhost:11434', model: 'llama3', messages: [{ role: 'user', content: 'hi' }], options: {}, onChunk: (text) => chunks.push(text) }); assertEqual(chunks.join(''), 'こんにちは、世界'); } finally { globalThis.fetch = realFetch; } }); it('forwards thinking chunks separately', async () => { const lines = [JSON.stringify({ message: { thinking: 'th', content: '' } }), JSON.stringify({ message: { content: 'ok' } })]; globalThis.fetch = async () => ndjsonResponse(lines, [5]); try { const seen = []; await chatStream({ host: 'http://localhost:11434', model: 'm', messages: [], options: {}, onChunk: (content, thinking) => seen.push({ content, thinking }) }); assertEqual(seen.length, 2); } finally { globalThis.fetch = realFetch; } }); it('skips malformed JSON lines without failing', async () => { const lines = ['{not json', JSON.stringify({ message: { content: 'ok' } })]; globalThis.fetch = async () => ndjsonResponse(lines, [4]); try { const chunks = []; await chatStream({ host: 'http://localhost:11434', model: 'm', messages: [], options: {}, onChunk: (t) => chunks.push(t) }); assertEqual(chunks.join(''), 'ok'); } finally { globalThis.fetch = realFetch; } }); it('maps stream error payloads to codes', async () => { const lines = [JSON.stringify({ error: 'model "x" not found' })]; globalThis.fetch = async () => ndjsonResponse(lines); try { try { await chatStream({ host: 'http://localhost:11434', model: 'm', messages: [], options: {}, onChunk: () => {} }); } catch (e) { assertEqual(e.code, ERROR_CODES.MODEL_NOT_FOUND); } } finally { globalThis.fetch = realFetch; } }); it('includes think and options only when set', async () => { let capturedBody = null; globalThis.fetch = async (url, init) => { capturedBody = JSON.parse(init.body); return ndjsonResponse([JSON.stringify({ done: true })]); }; try { await chatStream({ host: 'http://localhost:11434', model: 'm', messages: [{ role: 'user', content: 'x' }], think: 'low', options: { temperature: 0.5 }, onChunk: () => {} }); assertEqual(capturedBody.think, 'low'); assertEqual(capturedBody.stream, true); assertEqual(capturedBody.options.temperature, 0.5); } finally { globalThis.fetch = realFetch; } // think off -> omitted globalThis.fetch = async (url, init) => { capturedBody = JSON.parse(init.body); return ndjsonResponse([JSON.stringify({ done: true })]); }; try { await chatStream({ host: 'http://localhost:11434', model: 'm', messages: [], options: {}, onChunk: () => {} }); assertFalse('think' in capturedBody); assertFalse('options' in capturedBody); } finally { globalThis.fetch = realFetch; } }); it('maps 400 to INVALID_SETTINGS and 404 to MODEL_NOT_FOUND', async () => { globalThis.fetch = async () => errorResponse(400); try { try { await chatStream({ host: 'http://localhost:11434', model: 'm', messages: [], options: {}, onChunk: () => {} }); } catch (e) { assertEqual(e.code, ERROR_CODES.INVALID_SETTINGS); } } finally { globalThis.fetch = realFetch; } globalThis.fetch = async () => errorResponse(404); try { try { await chatStream({ host: 'http://localhost:11434', model: 'm', messages: [], options: {}, onChunk: () => {} }); } catch (e) { assertEqual(e.code, ERROR_CODES.MODEL_NOT_FOUND); } } finally { globalThis.fetch = realFetch; } }); }); describe('toExtensionError', () => { it('maps AbortError to REQUEST_ABORTED', () => { const e = toExtensionError(Object.assign(new Error('aborted'), { name: 'AbortError' })); assertEqual(e.code, ERROR_CODES.REQUEST_ABORTED); }); it('maps unknown to given default', () => { const e = toExtensionError(new TypeError('x'), { code: ERROR_CODES.STREAM_ERROR }); assertEqual(e.code, ERROR_CODES.STREAM_ERROR); }); }); // --------------------------------------------------------------------- // Integration: TranslationSession // --------------------------------------------------------------------- describe('TranslationSession', () => { it('start aborts the previous request and reports to its port', () => { const session = new TranslationSession(); const portA = fakePort(); const portB = fakePort(); let aborted = 0; const originalAbort = AbortController.prototype.abort; AbortController.prototype.abort = function () { aborted++; return originalAbort.call(this); }; let sent = []; portA.postMessage = (m) => sent.push(m); try { const controllerA = session.start('a', portA); assertTrue(session.isActive('a')); session.start('b', portB); assertEqual(aborted >= 1, true); assertFalse(session.isActive('a')); assertTrue(session.isActive('b')); assertEqual(sent[0] && sent[0].code, ERROR_CODES.REQUEST_ABORTED); } finally { AbortController.prototype.abort = originalAbort; } }); it('isActive/clear behave', () => { const session = new TranslationSession(); session.start('x', fakePort()); assertTrue(session.isActive('x')); session.clear('y'); assertTrue(session.isActive('x')); session.clear('x'); assertFalse(session.isActive('x')); }); }); // --------------------------------------------------------------------- // Integration: settings service (storage round-trip) // --------------------------------------------------------------------- describe('settings service', () => { it('save then load round-trips validated settings', async () => { const storage = fakeStorage(); const browserApi = { storage }; const saved = await saveSettings({ ...defaultSettings(), model: 'llama3', temperature: 0.7 }, browserApi); assertEqual(saved.model, 'llama3'); const loaded = await loadSettings(browserApi); assertEqual(loaded.model, 'llama3'); assertCloseTo(loaded.temperature, 0.7, 1e-9); }); it('invalid stored values fall back to defaults on load', async () => { const storage = fakeStorage(); await storage.local.set({ settings: { ollamaHost: 'http://evil.example', temperature: 50 } }); const loaded = await loadSettings({ storage }); assertEqual(loaded.ollamaHost, 'http://localhost:11434'); assertEqual(loaded.temperature, 0.2); }); it('storage.onChanged refreshes the cache', async () => { const storage = fakeStorage(); const browserApi = { storage }; await loadSettings(browserApi); // watchSettings uses storage.onChanged const { watchSettings } = await import('../src/background/settings-service.js'); watchSettings(browserApi); await storage.local.set({ settings: { ...defaultSettings(), model: 'qwen' } }); const { getSettings } = await import('../src/background/settings-service.js'); const current = await getSettings(browserApi); assertEqual(current.model, 'qwen'); }); }); // --------------------------------------------------------------------- // Integration: message router (port flow) // --------------------------------------------------------------------- function setupRouter({ settings, fetchResponses }) { const storage = fakeStorage(); const runtime = fakeRuntime(); const browserApi = { storage, runtime }; return (async () => { // Reset the shared settings cache so each test starts from its own // storage state (the module-level cache would otherwise leak between // tests and hide auto-select behavior). const svc = await import('../src/background/settings-service.js'); svc.__resetCacheForTests(); await saveSettings(settings, browserApi); if (fetchResponses) { globalThis.fetch = fetchResponses; } const router = await import('../src/background/message-router.js'); // Re-register a handler with our browserApi: emulate by calling the // exported registerRuntimeMessageHandler with injected api. router.__setBrowserApiForTests(browserApi); return { runtime, storage }; })(); } describe('message router: port translation flow', () => { it('translate -> started/chunk/completed', async () => { const lines = [ JSON.stringify({ message: { content: 'こん' } }), JSON.stringify({ message: { content: 'にちは' } }), JSON.stringify({ done: true }) ]; const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'llama3' }, fetchResponses: async () => ndjsonResponse(lines) }); const port = fakePort(); const sent = []; port.postMessage = (m) => sent.push(m); runtime.onConnect.__emit(port); port.__emit({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'r1', text: 'hello' }); await new Promise((resolve) => setTimeout(resolve, 30)); const types = sent.map((m) => m.type); assertEqual(types[0], BG_TO_CONTENT.STARTED); assertEqual(types.includes(BG_TO_CONTENT.CHUNK), true); assertEqual(types[types.length - 1], BG_TO_CONTENT.COMPLETED); const text = sent .filter((m) => m.type === BG_TO_CONTENT.CHUNK) .map((m) => m.text) .join(''); assertEqual(text, 'こんにちは'); }); it('newer request aborts older: stale chunks discarded', async () => { let resolveA; const blockA = new Promise((resolve) => { resolveA = resolve; }); let callCount = 0; globalThis.fetch = async () => { callCount++; if (callCount === 1) { // First request: long-lived stream that we resolve manually. const stream = new ReadableStream({ start(controller) { const enc = new TextEncoder(); controller.enqueue(enc.encode(JSON.stringify({ message: { content: 'A1' } }) + '\n')); resolveA = () => controller.close(); // keep open window.__streamAController = controller; window.__streamAEnc = enc; } }); return new Response(stream, { status: 200 }); } return ndjsonResponse([JSON.stringify({ message: { content: 'B' } }), JSON.stringify({ done: true })]); }; const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'llama3' }, fetchResponses: null }); const portA = fakePort(); const portB = fakePort(); const sentA = []; const sentB = []; portA.postMessage = (m) => sentA.push(m); portB.postMessage = (m) => sentB.push(m); runtime.onConnect.__emit(portA); runtime.onConnect.__emit(portB); portA.__emit({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'a', text: 'one' }); await new Promise((resolve) => setTimeout(resolve, 20)); portB.__emit({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'b', text: 'two' }); await new Promise((resolve) => setTimeout(resolve, 40)); if (window.__streamAController) window.__streamAController.close(); // A got started then an abort error; B got its chunks. assertTrue(sentA.some((m) => m.type === BG_TO_CONTENT.STARTED)); assertTrue(sentA.some((m) => m.type === BG_TO_CONTENT.ERROR && m.code === ERROR_CODES.REQUEST_ABORTED)); assertTrue(sentB.some((m) => m.type === BG_TO_CONTENT.CHUNK && m.text === 'B')); assertTrue(sentB.some((m) => m.type === BG_TO_CONTENT.COMPLETED)); globalThis.fetch = realFetch; }); it('malformed messages are discarded without response', async () => { const { runtime } = await setupRouter({ settings: defaultSettings(), fetchResponses: async () => ndjsonResponse([JSON.stringify({ done: true })]) }); const port = fakePort(); const sent = []; port.postMessage = (m) => sent.push(m); runtime.onConnect.__emit(port); port.__emit('garbage'); port.__emit(null); port.__emit({ type: CONTENT_TO_BG.TRANSLATE, requestId: '', text: 'x' }); port.__emit({ type: 'unknown-type', requestId: 'x' }); await new Promise((resolve) => setTimeout(resolve, 20)); assertEqual(sent.length, 0); globalThis.fetch = realFetch; }); it('ollama unavailable maps to OLLAMA_UNAVAILABLE error', async () => { globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); }; const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'llama3' }, fetchResponses: null }); const port = fakePort(); const sent = []; port.postMessage = (m) => sent.push(m); runtime.onConnect.__emit(port); port.__emit({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'r', text: 'hello' }); await new Promise((resolve) => setTimeout(resolve, 30)); assertTrue(sent.some((m) => m.type === BG_TO_CONTENT.ERROR && m.code === ERROR_CODES.OLLAMA_UNAVAILABLE)); globalThis.fetch = realFetch; }); it('no model configured maps to INVALID_SETTINGS', async () => { const { runtime } = await setupRouter({ settings: defaultSettings(), // model: '' fetchResponses: async () => ndjsonResponse([]) }); const port = fakePort(); const sent = []; port.postMessage = (m) => sent.push(m); runtime.onConnect.__emit(port); port.__emit({ type: CONTENT_TO_BG.TRANSLATE, requestId: 'r', text: 'hello' }); await new Promise((resolve) => setTimeout(resolve, 30)); assertTrue(sent.some((m) => m.type === BG_TO_CONTENT.ERROR && m.code === ERROR_CODES.INVALID_SETTINGS)); globalThis.fetch = realFetch; }); }); describe('message router: listModels runtime message', () => { it('returns models on success', async () => { globalThis.fetch = async () => new Response(JSON.stringify({ models: [{ name: 'm1' }] }), { status: 200 }); try { const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'm1' }, fetchResponses: null }); const listener = runtime.onMessage.__listener(); const result = await listener({ type: RUNTIME_MESSAGES.LIST_MODELS }, { id: 'ext' }); assertEqual(result.ok, true); assertEqual(result.models, ['m1']); } finally { globalThis.fetch = realFetch; } }); it('rejects untrusted senders', async () => { const { runtime } = await setupRouter({ settings: defaultSettings(), fetchResponses: async () => new Response('{}') }); const listener = runtime.onMessage.__listener(); const result = await listener({ type: RUNTIME_MESSAGES.LIST_MODELS }, {}); assertEqual(result.ok, false); globalThis.fetch = realFetch; }); }); // --------------------------------------------------------------------- // Context menu translation (PDF Viewer path) + popup state // --------------------------------------------------------------------- import { POPUP_STATUSES } from '../src/shared/constants.js'; describe('translation-service: selectionText validation', () => { it('validates untrusted selection strings', async () => { const { validateSelectionText } = await import('../src/background/translation-service.js'); const good = validateSelectionText(' hello world '); assertEqual(good.ok, true); assertEqual(good.text, 'hello world'); assertEqual(validateSelectionText(' ').ok, false); assertEqual(validateSelectionText('').ok, false); assertEqual(validateSelectionText(null).ok, false); assertEqual(validateSelectionText(42).ok, false); // control characters are stripped, leaving empty -> rejected assertEqual(validateSelectionText('\u0000\u0001').ok, false); // oversize selection rejected const big = 'a'.repeat(MAX_SELECTION_LENGTH + 1); assertEqual(validateSelectionText(big).ok, false); }); }); describe('context menu translation flow + popup state', () => { it('menu selection -> translation -> popup state streaming/completed', async () => { const lines = [ JSON.stringify({ message: { content: 'こんばんは' } }), JSON.stringify({ done: true }) ]; const { runtime, storage } = await setupRouter({ settings: { ...defaultSettings(), model: 'm1' }, fetchResponses: async () => ndjsonResponse(lines) }); const { startFromSelection } = await import('../src/background/menu-translation.js'); const started = startFromSelection('good evening'); assertEqual(started.ok, true); await new Promise((resolve) => setTimeout(resolve, 40)); const listener = runtime.onMessage.__listener(); const result = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); assertEqual(result.ok, true); assertEqual(result.state.status, POPUP_STATUSES.COMPLETED); assertEqual(result.state.translatedText, 'こんばんは'); assertEqual(result.state.model, 'm1'); assertEqual(result.state.source, 'context-menu'); assertEqual(result.state.sourceText, 'good evening'); assertEqual(result.state.error, null); }); it('rejects empty selection without starting a request', async () => { const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'm1' }, fetchResponses: async () => ndjsonResponse([]) }); const { startFromSelection } = await import('../src/background/menu-translation.js'); const result = startFromSelection(' '); assertEqual(result.ok, false); assertEqual(result.error, ERROR_CODES.TEXT_EMPTY); globalThis.fetch = realFetch; }); it('popup state shows streaming while chunks arrive and completed at end', async () => { const lines = [ JSON.stringify({ message: { content: 'あ' } }), JSON.stringify({ message: { content: 'い' } }), JSON.stringify({ done: true }) ]; const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'm1' }, fetchResponses: async () => ndjsonResponse(lines, [1]) // 1-byte chunks: slow stream }); const { startFromSelection } = await import('../src/background/menu-translation.js'); startFromSelection('test text'); // Poll state while streaming: at some point status must be streaming let sawStreaming = false; const listener = runtime.onMessage.__listener(); for (let i = 0; i < 30; i++) { const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); if (s.state.status === POPUP_STATUSES.STREAMING) { sawStreaming = true; break; } await new Promise((resolve) => setTimeout(resolve, 5)); } await new Promise((resolve) => setTimeout(resolve, 60)); const final = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); assertTrue(sawStreaming || final.state.status === POPUP_STATUSES.COMPLETED, 'should reach streaming or completed'); assertEqual(final.state.status, POPUP_STATUSES.COMPLETED); assertEqual(final.state.translatedText, 'あい'); globalThis.fetch = realFetch; }); it('ollama unavailable surfaces an error into popup state', async () => { globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); }; const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'm1' }, fetchResponses: null }); const { startFromSelection } = await import('../src/background/menu-translation.js'); startFromSelection('hello'); await new Promise((resolve) => setTimeout(resolve, 40)); const listener = runtime.onMessage.__listener(); const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); assertEqual(s.state.status, POPUP_STATUSES.ERROR); assertTrue(typeof s.state.error === 'string' && s.state.error.length > 0); globalThis.fetch = realFetch; }); it('new menu translation aborts the previous one (state cancelled)', async () => { // First stream: long-lived, manually closed. let controllerA = null; let callCount = 0; globalThis.fetch = async () => { callCount++; if (callCount === 1) { const stream = new ReadableStream({ start(c) { controllerA = c; const enc = new TextEncoder(); c.enqueue(enc.encode(JSON.stringify({ message: { content: 'A1' } }) + '\n')); } }); return new Response(stream, { status: 200 }); } return ndjsonResponse([JSON.stringify({ message: { content: 'B' } }), JSON.stringify({ done: true })]); }; const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'm1' }, fetchResponses: null }); const { startFromSelection } = await import('../src/background/menu-translation.js'); startFromSelection('first'); await new Promise((resolve) => setTimeout(resolve, 30)); startFromSelection('second'); await new Promise((resolve) => setTimeout(resolve, 60)); if (controllerA) controllerA.close(); const listener = runtime.onMessage.__listener(); const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); // The latest request (B) must own the state. assertEqual(s.state.sourceText, 'second'); assertEqual(s.state.translatedText, 'B'); assertTrue(s.state.status === POPUP_STATUSES.COMPLETED || s.state.status === POPUP_STATUSES.STREAMING); globalThis.fetch = realFetch; }); it('regenerateLast reuses the stored source text with a new requestId', async () => { const lines = [JSON.stringify({ message: { content: 'OK' } }), JSON.stringify({ done: true })]; globalThis.fetch = async () => ndjsonResponse(lines); const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'm1' }, fetchResponses: null }); const { startFromSelection } = await import('../src/background/menu-translation.js'); startFromSelection('original text'); await new Promise((resolve) => setTimeout(resolve, 40)); const listener = runtime.onMessage.__listener(); const before = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); const regen = await listener({ type: RUNTIME_MESSAGES.REGENERATE_LAST }, { id: 'ext' }); assertEqual(regen.ok, true); assertTrue(regen.requestId !== before.state.requestId); await new Promise((resolve) => setTimeout(resolve, 40)); const after = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); assertEqual(after.state.sourceText, 'original text'); assertEqual(after.state.status, POPUP_STATUSES.COMPLETED); assertEqual(after.state.translatedText, 'OK'); globalThis.fetch = realFetch; }); it('popup-state port receives live state broadcasts while streaming', async () => { const lines = [ JSON.stringify({ message: { content: 'あ' } }), JSON.stringify({ message: { content: 'い' } }), JSON.stringify({ done: true }) ]; const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'm1' }, fetchResponses: async () => ndjsonResponse(lines, [1]) }); // Register the popup-state handler (in production index.js registers // both onConnect listeners; in tests we register explicitly). const router = await import('../src/background/message-router.js'); const popupPort = fakePort(); popupPort.name = 'popup-state'; const received = []; popupPort.postMessage = (m) => received.push(m); router.handlePopupStateConnection(popupPort); const { startFromSelection } = await import('../src/background/menu-translation.js'); startFromSelection('live test'); await new Promise((resolve) => setTimeout(resolve, 80)); // Initial state push + per-chunk broadcasts + final completed broadcast assertTrue(received.length >= 2, 'should receive initial + live broadcasts'); const last = received[received.length - 1]; assertEqual(last.type, 'popup-state'); assertEqual(last.state.translatedText, 'あい'); assertTrue( last.state.status === POPUP_STATUSES.COMPLETED || last.state.status === POPUP_STATUSES.STREAMING, 'final broadcast should be completed (or streaming for the last chunk)' ); globalThis.fetch = realFetch; }); it('handlePopupStateConnection ignores ports with wrong name', async () => { const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'm1' }, fetchResponses: async () => ndjsonResponse([JSON.stringify({ done: true })]) }); const router = await import('../src/background/message-router.js'); const wrongPort = fakePort(); wrongPort.name = 'translation-stream'; // content-script port, not popup const received = []; wrongPort.postMessage = (m) => received.push(m); // Emit through popup-state handler: it must ignore non-popup ports. router.handlePopupStateConnection(wrongPort); await new Promise((resolve) => setTimeout(resolve, 20)); assertEqual(received.length, 0); globalThis.fetch = realFetch; }); it('auto-selects the first Ollama model when storage has none (fresh install)', async () => { // settings.model is '' (defaultSettings) and Ollama responds with a list. globalThis.fetch = async (url) => { if (String(url).endsWith('/api/tags')) { return new Response(JSON.stringify({ models: [{ name: 'first-model' }, { name: 'second-model' }] }), { status: 200 }); } return ndjsonResponse([JSON.stringify({ message: { content: 'OK' } }), JSON.stringify({ done: true })]); }; const { runtime, storage } = await setupRouter({ settings: defaultSettings(), // model: '' fetchResponses: null }); // runTranslation resolves the browser API from globalThis.browser when // no browserApi is passed (production behavior); provide it for tests. globalThis.browser = { storage, runtime }; const { startFromSelection } = await import('../src/background/menu-translation.js'); const started = startFromSelection('fresh install test'); assertEqual(started.ok, true); await new Promise((resolve) => setTimeout(resolve, 60)); const listener = runtime.onMessage.__listener(); const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); // The translation must have started with the auto-picked model. assertEqual(s.state.model, 'first-model'); assertEqual(s.state.status, POPUP_STATUSES.COMPLETED); // And the model must be persisted so the settings UI shows it. const stored = await storage.local.get({ settings: null }); assertEqual(stored.settings && stored.settings.model, 'first-model'); globalThis.fetch = realFetch; }); it('reports OLLAMA_UNAVAILABLE when no model configured and Ollama is down', async () => { globalThis.fetch = async () => { throw new TypeError('Failed to fetch'); }; const { runtime } = await setupRouter({ settings: defaultSettings(), // model: '' fetchResponses: null }); const { startFromSelection } = await import('../src/background/menu-translation.js'); startFromSelection('offline test'); await new Promise((resolve) => setTimeout(resolve, 50)); const listener = runtime.onMessage.__listener(); const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); assertEqual(s.state.status, POPUP_STATUSES.ERROR); // User-safe message: no raw exception text. assertFalse(/Failed to fetch/i.test(s.state.error)); delete globalThis.browser; globalThis.fetch = realFetch; }); it('error detail is user-safe: URLs stripped, length capped, JSON dropped', async () => { // Ollama returns a 400 with a JSON body including an internal URL. globalThis.fetch = async () => new Response(JSON.stringify({ error: 'bad request see http://localhost:11434/internal for more {huge json' }), { status: 400 }); const { runtime } = await setupRouter({ settings: { ...defaultSettings(), model: 'm1' }, fetchResponses: null }); const { startFromSelection } = await import('../src/background/menu-translation.js'); startFromSelection('error detail test'); await new Promise((resolve) => setTimeout(resolve, 50)); const listener = runtime.onMessage.__listener(); const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); assertEqual(s.state.status, POPUP_STATUSES.ERROR); assertFalse(/http:\/\//.test(s.state.error)); delete globalThis.browser; globalThis.fetch = realFetch; }); }); run();