summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/all-tests.js790
-rw-r--r--tests/content-constants.js28
-rw-r--r--tests/mocks.js126
-rw-r--r--tests/runner.js128
-rw-r--r--tests/test.html29
5 files changed, 1101 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
diff --git a/tests/content-constants.js b/tests/content-constants.js
new file mode 100644
index 0000000..2f274fc
--- /dev/null
+++ b/tests/content-constants.js
@@ -0,0 +1,28 @@
+/**
+ * Constants mirrored from the content script (src/content/content.js).
+ * Used by tests to verify the classic-script mirror stays in sync with
+ * src/shared/constants.js.
+ */
+(() => {
+ 'use strict';
+
+ const PORT_NAME = 'translation-stream';
+ const MAX_SELECTION_LENGTH = 20000;
+ const ERROR_CODES = {
+ INVALID_REQUEST: 'INVALID_REQUEST',
+ TEXT_EMPTY: 'TEXT_EMPTY',
+ TEXT_TOO_LONG: 'TEXT_TOO_LONG',
+ OLLAMA_UNAVAILABLE: 'OLLAMA_UNAVAILABLE',
+ MODEL_NOT_FOUND: 'MODEL_NOT_FOUND',
+ INVALID_SETTINGS: 'INVALID_SETTINGS',
+ REQUEST_ABORTED: 'REQUEST_ABORTED',
+ STREAM_ERROR: 'STREAM_ERROR',
+ UNKNOWN_ERROR: 'UNKNOWN_ERROR'
+ };
+ const CONTENT_TO_BG = { TRANSLATE: 'translate', CANCEL: 'cancel' };
+ const BG_TO_CONTENT = { STARTED: 'started', CHUNK: 'chunk', COMPLETED: 'completed', ERROR: 'error' };
+
+ window.contentConstantsForSyncCheck = { PORT_NAME, MAX_SELECTION_LENGTH, ERROR_CODES, CONTENT_TO_BG, BG_TO_CONTENT };
+})();
+
+export const contentConstantsForSyncCheck = window.contentConstantsForSyncCheck; \ No newline at end of file
diff --git a/tests/mocks.js b/tests/mocks.js
new file mode 100644
index 0000000..d8b9918
--- /dev/null
+++ b/tests/mocks.js
@@ -0,0 +1,126 @@
+/**
+ * Test mocks: fetch (NDJSON stream with controllable chunk boundaries),
+ * browser storage, and browser runtime port.
+ */
+(() => {
+ 'use strict';
+
+ /**
+ * Create a Response whose body streams the given NDJSON lines, split into
+ * arbitrary byte chunks (to test JSON-boundary tolerance).
+ * @param {string[]} lines NDJSON lines
+ * @param {number[]} [chunkSizes] byte sizes per chunk (cycled)
+ * @returns {Response}
+ */
+ function ndjsonResponse(lines, chunkSizes) {
+ const text = lines.join('\n') + '\n';
+ const bytes = new TextEncoder().encode(text);
+ const sizes = chunkSizes && chunkSizes.length ? chunkSizes : [bytes.length];
+ let offset = 0;
+ let index = 0;
+ const stream = new ReadableStream({
+ pull(controller) {
+ if (offset >= bytes.length) {
+ controller.close();
+ return;
+ }
+ const size = sizes[index % sizes.length];
+ index++;
+ const end = Math.min(offset + size, bytes.length);
+ controller.enqueue(bytes.slice(offset, end));
+ offset = end;
+ }
+ });
+ return new Response(stream, { status: 200, headers: { 'Content-Type': 'application/x-ndjson' } });
+ }
+
+ /**
+ * @param {number} status
+ * @returns {Response}
+ */
+ function errorResponse(status) {
+ return new Response(JSON.stringify({ error: 'boom' }), { status, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ /**
+ * Fake browser.storage.local backed by a Map.
+ * @returns {object} browser-like API
+ */
+ function fakeStorage() {
+ const data = new Map();
+ const listeners = [];
+ const local = {
+ async get(defaults) {
+ const result = {};
+ for (const key of Object.keys(defaults)) {
+ result[key] = data.has(key) ? data.get(key) : defaults[key];
+ }
+ return result;
+ },
+ async set(obj) {
+ const changes = {};
+ for (const [key, value] of Object.entries(obj)) {
+ changes[key] = { oldValue: data.get(key), newValue: value };
+ data.set(key, value);
+ }
+ for (const listener of listeners) listener(changes, 'local');
+ }
+ };
+ return {
+ local,
+ onChanged: {
+ addListener(fn) {
+ listeners.push(fn);
+ }
+ }
+ };
+ }
+
+ /**
+ * Fake runtime.Port with a message queue.
+ */
+ function fakePort() {
+ const listeners = { message: [], disconnect: [] };
+ return {
+ name: 'translation-stream',
+ sender: { id: 'test-extension' },
+ postMessage: () => {},
+ onMessage: {
+ addListener: (fn) => listeners.message.push(fn)
+ },
+ onDisconnect: {
+ addListener: (fn) => listeners.disconnect.push(fn)
+ },
+ __emit(message) {
+ for (const fn of listeners.message) fn(message);
+ },
+ __emitDisconnect() {
+ for (const fn of listeners.disconnect) fn();
+ }
+ };
+ }
+
+ /**
+ * Fake browser.runtime sufficient for message-router tests.
+ */
+ function fakeRuntime() {
+ const connectListeners = [];
+ const messageListeners = [];
+ return {
+ onConnect: {
+ addListener: (fn) => connectListeners.push(fn),
+ __emit(port) {
+ for (const fn of connectListeners) fn(port);
+ }
+ },
+ onMessage: {
+ addListener: (fn) => messageListeners.push(fn),
+ __listener() {
+ return messageListeners[messageListeners.length - 1];
+ }
+ }
+ };
+ }
+
+ window.TestMocks = { ndjsonResponse, errorResponse, fakeStorage, fakePort, fakeRuntime };
+})(); \ No newline at end of file
diff --git a/tests/runner.js b/tests/runner.js
new file mode 100644
index 0000000..bfafa10
--- /dev/null
+++ b/tests/runner.js
@@ -0,0 +1,128 @@
+/**
+ * Minimal in-browser test runner (no npm / no Node).
+ * Provides describe/it/assert and renders results into the document.
+ */
+(() => {
+ 'use strict';
+
+ const suites = [];
+ let currentSuite = null;
+ const results = [];
+
+ /**
+ * @param {string} name
+ * @param {() => void} fn
+ */
+ function describe(name, fn) {
+ currentSuite = { name, tests: [] };
+ suites.push(currentSuite);
+ fn();
+ currentSuite = null;
+ }
+
+ /**
+ * @param {string} name
+ * @param {() => void|Promise<void>} fn
+ */
+ function it(name, fn) {
+ if (!currentSuite) throw new Error('it() outside describe()');
+ currentSuite.tests.push({ name, fn });
+ }
+
+ class AssertionError extends Error {}
+
+ /**
+ * @param {*} actual
+ * @param {*} expected
+ * @param {string} [message]
+ */
+ function assertEqual(actual, expected, message) {
+ const a = JSON.stringify(actual);
+ const b = JSON.stringify(expected);
+ if (a !== b) {
+ throw new AssertionError(message || `expected ${b}, got ${a}`);
+ }
+ }
+
+ /**
+ * @param {*} actual
+ * @param {string} [message]
+ */
+ function assertTrue(actual, message) {
+ if (actual !== true) {
+ throw new AssertionError(message || `expected true, got ${JSON.stringify(actual)}`);
+ }
+ }
+
+ /**
+ * @param {*} actual
+ * @param {string} [message]
+ */
+ function assertFalse(actual, message) {
+ if (actual !== false) {
+ throw new AssertionError(message || `expected false, got ${JSON.stringify(actual)}`);
+ }
+ }
+
+ /**
+ * @param {number} actual
+ * @param {number} expected
+ * @param {number} epsilon
+ * @param {string} [message]
+ */
+ function assertCloseTo(actual, expected, epsilon, message) {
+ if (Math.abs(actual - expected) > epsilon) {
+ throw new AssertionError(message || `expected ~${expected}, got ${actual}`);
+ }
+ }
+
+ /**
+ * @param {Function} fn
+ * @param {string} [message]
+ */
+ async function assertThrowsAsync(fn, message) {
+ try {
+ await fn();
+ } catch {
+ return;
+ }
+ throw new AssertionError(message || 'expected function to throw');
+ }
+
+ async function run() {
+ const root = document.getElementById('results');
+ const summary = document.getElementById('summary');
+ let passed = 0;
+ let failed = 0;
+
+ for (const suite of suites) {
+ const suiteEl = document.createElement('section');
+ const heading = document.createElement('h2');
+ heading.textContent = suite.name;
+ suiteEl.append(heading);
+
+ for (const test of suite.tests) {
+ const row = document.createElement('div');
+ row.className = 'test';
+ try {
+ await test.fn();
+ passed++;
+ row.classList.add('pass');
+ row.textContent = `✔ ${test.name}`;
+ } catch (error) {
+ failed++;
+ row.classList.add('fail');
+ row.textContent = `✘ ${test.name} — ${error && error.message ? error.message : String(error)}`;
+ }
+ suiteEl.append(row);
+ }
+ root.append(suiteEl);
+ }
+
+ summary.textContent = `${passed} passed, ${failed} failed`;
+ summary.className = failed === 0 ? 'all-pass' : 'has-failures';
+ document.title = failed === 0 ? 'PASS - Tests' : 'FAIL - Tests';
+ }
+
+ window.TestRunner = { describe, it, assertEqual, assertTrue, assertFalse, assertCloseTo, assertThrowsAsync, run };
+})(); \ No newline at end of file
diff --git a/tests/test.html b/tests/test.html
new file mode 100644
index 0000000..7983b17
--- /dev/null
+++ b/tests/test.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="ja">
+<head>
+ <meta charset="utf-8">
+ <title>Ollama Translations - Tests</title>
+ <style>
+ body { font-family: system-ui, sans-serif; margin: 24px; background: #fafafa; }
+ h1 { font-size: 18px; }
+ h2 { font-size: 14px; margin: 18px 0 6px; }
+ .test { font-size: 13px; padding: 2px 0 2px 12px; }
+ .pass { color: #188038; }
+ .fail { color: #c5221f; }
+ #summary { font-size: 15px; font-weight: 600; }
+ .all-pass { color: #188038; }
+ .has-failures { color: #c5221f; }
+ section { background: #fff; border: 1px solid #ddd; border-radius: 6px; padding: 8px 16px; margin-bottom: 10px; }
+ </style>
+</head>
+<body>
+ <h1>Ollama Translations - Test Suite</h1>
+ <p id="summary">running…</p>
+ <div id="results"></div>
+
+ <!-- Shared modules as ES modules via dynamic import in tests -->
+ <script src="runner.js"></script>
+ <script src="mocks.js"></script>
+ <script type="module" src="all-tests.js"></script>
+</body>
+</html> \ No newline at end of file