diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-09-01 20:10:20 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-09-01 20:10:20 +0900 |
| commit | 55e581bab9b600e7c070ec596d3109fc897d2d86 (patch) | |
| tree | 15ca196a1858696c216946dc9608fae613ad6e5f /tests | |
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')
| -rw-r--r-- | tests/all-tests.js | 790 | ||||
| -rw-r--r-- | tests/content-constants.js | 28 | ||||
| -rw-r--r-- | tests/mocks.js | 126 | ||||
| -rw-r--r-- | tests/runner.js | 128 | ||||
| -rw-r--r-- | tests/test.html | 29 |
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 |
