From 55e581bab9b600e7c070ec596d3109fc897d2d86 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Tue, 1 Sep 2026 20:10:20 +0900 Subject: feat: Firefox selection translation extension with local Ollama (initial version) Implement the complete initial version of a Firefox WebExtension (Manifest V3) that translates selected text on web pages using a local Ollama LLM and shows the result in a small popover near the selection. Specification alignment (Phase 0) - Unified Specifications.md / Design.md / Security.md on the same approach: Ollama REST API + Fetch API + NDJSON streaming (no ollama-js SDK dependency) - Fixed host_permissions to http://localhost/* and http://127.0.0.1/* because Firefox match patterns do not support port numbers (bug 1362809/1468162); the actual connection target is restricted to localhost/127.0.0.1 + any port by application-side validation (defense in depth, OLL-09) - Clipboard: use navigator.clipboard.writeText() only inside the copy button click (user activation); no deprecated execCommand fallback Implementation (zero dependencies, vanilla JS, no npm/Node required) - manifest.json: MV3, background.scripts + type:module (event page, no service_worker), action popup, options_ui, content_scripts for http/https, permissions: storage + localhost host permissions only - src/shared: constants (ports, limits, presets, error codes), message validation, settings schema (defaults / merge / sanitize / buildOptions / toApiThinking), system prompt builder (fixed prompt first + user prompt) - src/background: message-router (runtime.Port streaming + runtime.onMessage for model list), translation-session (single active request, abort previous on new request, stale chunk suppression), ollama-client (fetch against /api/tags and /api/chat with buffering NDJSON line parser tolerant to chunk boundaries inside JSON objects, AbortController, host re-validation before every request, HTTP status to error-code mapping), settings-service (storage.local load/save with validation and fallback to defaults) - src/content/content.js: single classic script (Firefox does not support ES modules in content scripts) with selection detection (selectionchange + mouseup + keyup, debounced), Shadow DOM action button, Shadow DOM popover with HIDDEN/LOADING/STREAMING/COMPLETED/ERROR state machine, port client with requestId-based stale chunk filtering, enabled-state sync via storage.onChanged; all dynamic text rendered via textContent only - src/popup + src/options: full settings UI (enable toggle, Ollama host, model list from Ollama with saved-model warning, target language, thinking mode, temperature slider 0.0-2.0 step 0.1, context length presets 4k/8k/16k/32k/128k/256k, seed random/fixed with integer value, user system prompt, advanced numeric options with steppers, stop sequences textarea) - icons generated by tools/make-icons.ps1 (System.Drawing, no external tools) - build.ps1 packages manifest.json + icons/ + src/ into dist zip, excluding tests/, doc/, ollama-js/ and tools/ Tests (71 passed, 0 failed) - In-browser test runner (tests/test.html) with custom describe/it/assert framework; no npm, no Node.js - Unit: constants sync between content mirror and shared module, Ollama host validation (localhost/127.0.0.1 + any port only; rejects external hosts, LAN IPs, https, credentials, paths), settings schema defaults and fallback, temperature 0.1-step validation, context length presets, seed modes, advanced option types, buildOptions omission of unset values and absence of tfs_z/mirostat fields, system prompt composition order, message validation with selection length boundary values - Integration: NDJSON streaming across awkward byte boundaries, thinking chunk forwarding, malformed line tolerance, error payload mapping, think/ options inclusion rules, model list, TranslationSession abort semantics, settings round-trip with storage.onChanged cache refresh, full port flow (started/chunk/completed), newer request aborts older with stale chunk discard, malformed message rejection, OLLAMA_UNAVAILABLE and INVALID_SETTINGS error paths, untrusted sender rejection Security audit (Design.md section 24 checklist: all pass) - No innerHTML/outerHTML/document.write/eval/new Function/localStorage - No remote JS/CSS/fonts/CDN resources; no inline scripts in extension pages - Ollama access limited to background; content scripts never fetch Ollama - Background validates message type/length/requestId; single concurrent translation enforced - Clipboard API only (no execCommand fallback) - Minimal permissions: storage + localhost host permissions Docs - README.md with build/load/usage instructions, API list, permission rationale - doc/Testing-Results.md with automated test results, security audit results, and a 14-item manual Firefox verification checklist (to be executed on a real Firefox + Ollama environment) --- tests/all-tests.js | 790 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 790 insertions(+) create mode 100644 tests/all-tests.js (limited to 'tests/all-tests.js') diff --git a/tests/all-tests.js b/tests/all-tests.js new file mode 100644 index 0000000..2836203 --- /dev/null +++ b/tests/all-tests.js @@ -0,0 +1,790 @@ +/** + * 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 () => { + 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; + }); +}); + +run(); \ No newline at end of file -- cgit v1.2.3