summaryrefslogtreecommitdiff
path: root/tests/all-tests.js
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-09-01 20:10:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-09-01 20:10:20 +0900
commit55e581bab9b600e7c070ec596d3109fc897d2d86 (patch)
tree15ca196a1858696c216946dc9608fae613ad6e5f /tests/all-tests.js
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)
Diffstat (limited to 'tests/all-tests.js')
-rw-r--r--tests/all-tests.js790
1 files changed, 790 insertions, 0 deletions
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(['<END>']));
+ 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: ['<END>'] }
+ });
+ const o = buildOptions(s);
+ assertEqual(o.top_k, 40);
+ assertEqual(o.top_p, 0.9);
+ assertEqual(o.num_predict, -1);
+ assertEqual(o.stop, ['<END>']);
+ });
+ 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