diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-09-02 03:14:05 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-09-02 03:14:05 +0900 |
| commit | 82bb55f66071b452e3bc7adeb09eafb5003de11f (patch) | |
| tree | 01d48a68273594fc2ed480d837724562dd6fb05d /tests/all-tests.js | |
| parent | f5439335e1d18ee577e2b7c1e67f7844f9f235ac (diff) | |
feat: PDF Viewer translation via context menu and two-mode Action Popupv0.1.0
Add Firefox built-in PDF Viewer support using only public WebExtensions
APIs: a selection context menu ("選択したテキストを翻訳") starts a
translation and the toolbar Action Popup shows the streaming result.
No content-script injection into the privileged PDF Viewer, no PDF.js
internals, no separate windows, no custom viewer (Specifications.md §4.6).
Specification alignment (Phase 1)
- Specifications.md: new requirements PDF-01..04 (PDF selection
translation), CTX-01..03 (context menu translation), POP-01..06
(popup two modes); SEL-06 updated to route PDF through the menus API;
section 13.4 adds a 14-item manual PDF verification checklist; section
15 removes "Firefox内蔵PDF Viewerへの対応" from out-of-scope
- Design.md: architecture diagram with the menus path, menu-translation
component, menus permission, popup translation-result state machine
(idle/starting/streaming/completed/error/cancelled), shared translation
state (§9.2), context-menu integration phase, test plan and API audit
checklist items (menus permission scope, no PDF Viewer internals)
- Security.md: menus permission restricted to selection-context
translation; PDF Viewer treated as privileged browser UI (no injection,
no wrappedJSObject/cloneInto/exportFunction, no internal DOM access);
menus.OnClickData.selectionText treated as untrusted input passing the
same validation pipeline; popup result rendering stays textContent-only
Background (Phase 2)
- translation-service.js (new): the single translation pipeline shared by
every entry point - selection validation (type/trim/empty/control
chars/max length), prompt building, Ollama request, session/abort, and
the shared translation state (requestId, sourceText, translatedText,
status, model, error, startedAt, source). "source" is metadata only; a
PDF-specific translation engine does not exist
- menu-translation.js (new): validates menus.OnClickData.selectionText
and starts a sink-less translation observed via GET_POPUP_STATE
- message-router.js: routes port translations through the shared service;
new runtime messages GET_POPUP_STATE and REGENERATE_LAST; popup-state
port handler broadcasts live state (initial push + every chunk/status
change) so an open popup shows streaming progress in real time
- index.js: registers the selection context menu (contexts: ["selection"]
only, no menus.onShown) and calls browser.action.openPopup()
synchronously inside the click user gesture, catching failures so a
popup-open problem never breaks the translation itself
- manifest.json: permissions now ["storage", "menus"]
- settings-service.js: __resetCacheForTests hook for test isolation
Popup two-mode UI (Phase 3)
- popup.html/js/css: mode nav (設定 / 翻訳). Translation mode shows
status, model, streaming text (textContent only), user-safe error, and
copy (Clipboard API only, no execCommand fallback), regenerate (new
requestId reusing the stored source text) and a settings shortcut.
Closing the popup never cancels the request; reopening shows the
latest text and continues receiving chunks live. Initial mode: active
(starting/streaming) translation wins, otherwise the last tab the user
picked is restored (storage.local.popupMode, default settings)
Fresh-install model auto-selection
- translation-service.js ensureModelConfigured(): when storage.model is
empty, pick the first model from the running Ollama and persist it
before translating, mirroring the popup/options auto-select. A user who
never opened any settings UI can translate immediately; when Ollama is
unreachable a clear OLLAMA_UNAVAILABLE message is shown
User-safe error details
- detailForUser(): strips URLs (no internal host names echoed to the UI),
caps length at 120 chars, drops full JSON response bodies, and hides
meaningless low-level network errors so the popover/popup shows the
base message plus at most a short, safe detail
Tests: 85 passed, 0 failed
- selectionText validation (untrusted input pipeline)
- menu selection -> translation -> popup state streaming/completed
- empty selection rejected without starting a request
- streaming status transitions observed through GET_POPUP_STATE
- Ollama unreachable surfaces a user-safe error into popup state
- newer request replaces older state (abort semantics)
- regenerateLast reuses source text with a fresh requestId
- popup-state port receives live broadcasts; wrong port names ignored
- fresh-install auto-select persists the first Ollama model
- error detail sanitization (URL stripping, length cap, JSON drop)
- popup/options smoke tests updated for the two-mode markup
- setupRouter resets the shared settings cache for test isolation
Docs: README documents the two-mode popup and PDF flow; Testing-Results
records automated results and the manual PDF checklist (to be executed).
No git-side surprises: this commit was created explicitly on request
(requirement 18 of the PDF task had deferred commits until now).
Diffstat (limited to 'tests/all-tests.js')
| -rw-r--r-- | tests/all-tests.js | 300 |
1 files changed, 300 insertions, 0 deletions
diff --git a/tests/all-tests.js b/tests/all-tests.js index 2836203..d4430e2 100644 --- a/tests/all-tests.js +++ b/tests/all-tests.js @@ -618,6 +618,11 @@ function setupRouter({ settings, fetchResponses }) { const runtime = fakeRuntime(); const browserApi = { storage, runtime }; return (async () => { + // Reset the shared settings cache so each test starts from its own + // storage state (the module-level cache would otherwise leak between + // tests and hide auto-select behavior). + const svc = await import('../src/background/settings-service.js'); + svc.__resetCacheForTests(); await saveSettings(settings, browserApi); if (fetchResponses) { globalThis.fetch = fetchResponses; @@ -787,4 +792,299 @@ describe('message router: listModels runtime message', () => { }); }); +// --------------------------------------------------------------------- +// Context menu translation (PDF Viewer path) + popup state +// --------------------------------------------------------------------- + +import { POPUP_STATUSES } from '../src/shared/constants.js'; + +describe('translation-service: selectionText validation', () => { + it('validates untrusted selection strings', async () => { + const { validateSelectionText } = await import('../src/background/translation-service.js'); + const good = validateSelectionText(' hello world '); + assertEqual(good.ok, true); + assertEqual(good.text, 'hello world'); + + assertEqual(validateSelectionText(' ').ok, false); + assertEqual(validateSelectionText('').ok, false); + assertEqual(validateSelectionText(null).ok, false); + assertEqual(validateSelectionText(42).ok, false); + // control characters are stripped, leaving empty -> rejected + assertEqual(validateSelectionText('\u0000\u0001').ok, false); + // oversize selection rejected + const big = 'a'.repeat(MAX_SELECTION_LENGTH + 1); + assertEqual(validateSelectionText(big).ok, false); + }); +}); + +describe('context menu translation flow + popup state', () => { + it('menu selection -> translation -> popup state streaming/completed', async () => { + const lines = [ + JSON.stringify({ message: { content: 'こんばんは' } }), + JSON.stringify({ done: true }) + ]; + const { runtime, storage } = await setupRouter({ + settings: { ...defaultSettings(), model: 'm1' }, + fetchResponses: async () => ndjsonResponse(lines) + }); + const { startFromSelection } = await import('../src/background/menu-translation.js'); + const started = startFromSelection('good evening'); + assertEqual(started.ok, true); + await new Promise((resolve) => setTimeout(resolve, 40)); + + const listener = runtime.onMessage.__listener(); + const result = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); + assertEqual(result.ok, true); + assertEqual(result.state.status, POPUP_STATUSES.COMPLETED); + assertEqual(result.state.translatedText, 'こんばんは'); + assertEqual(result.state.model, 'm1'); + assertEqual(result.state.source, 'context-menu'); + assertEqual(result.state.sourceText, 'good evening'); + assertEqual(result.state.error, null); + }); + + it('rejects empty selection without starting a request', async () => { + const { runtime } = await setupRouter({ + settings: { ...defaultSettings(), model: 'm1' }, + fetchResponses: async () => ndjsonResponse([]) + }); + const { startFromSelection } = await import('../src/background/menu-translation.js'); + const result = startFromSelection(' '); + assertEqual(result.ok, false); + assertEqual(result.error, ERROR_CODES.TEXT_EMPTY); + globalThis.fetch = realFetch; + }); + + it('popup state shows streaming while chunks arrive and completed at end', async () => { + const lines = [ + JSON.stringify({ message: { content: 'あ' } }), + JSON.stringify({ message: { content: 'い' } }), + JSON.stringify({ done: true }) + ]; + const { runtime } = await setupRouter({ + settings: { ...defaultSettings(), model: 'm1' }, + fetchResponses: async () => ndjsonResponse(lines, [1]) // 1-byte chunks: slow stream + }); + const { startFromSelection } = await import('../src/background/menu-translation.js'); + startFromSelection('test text'); + // Poll state while streaming: at some point status must be streaming + let sawStreaming = false; + const listener = runtime.onMessage.__listener(); + for (let i = 0; i < 30; i++) { + const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); + if (s.state.status === POPUP_STATUSES.STREAMING) { + sawStreaming = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await new Promise((resolve) => setTimeout(resolve, 60)); + const final = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); + assertTrue(sawStreaming || final.state.status === POPUP_STATUSES.COMPLETED, 'should reach streaming or completed'); + assertEqual(final.state.status, POPUP_STATUSES.COMPLETED); + assertEqual(final.state.translatedText, 'あい'); + globalThis.fetch = realFetch; + }); + + it('ollama unavailable surfaces an error into popup state', async () => { + globalThis.fetch = async () => { + throw new TypeError('Failed to fetch'); + }; + const { runtime } = await setupRouter({ + settings: { ...defaultSettings(), model: 'm1' }, + fetchResponses: null + }); + const { startFromSelection } = await import('../src/background/menu-translation.js'); + startFromSelection('hello'); + await new Promise((resolve) => setTimeout(resolve, 40)); + const listener = runtime.onMessage.__listener(); + const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); + assertEqual(s.state.status, POPUP_STATUSES.ERROR); + assertTrue(typeof s.state.error === 'string' && s.state.error.length > 0); + globalThis.fetch = realFetch; + }); + + it('new menu translation aborts the previous one (state cancelled)', async () => { + // First stream: long-lived, manually closed. + let controllerA = null; + let callCount = 0; + globalThis.fetch = async () => { + callCount++; + if (callCount === 1) { + const stream = new ReadableStream({ + start(c) { + controllerA = c; + const enc = new TextEncoder(); + c.enqueue(enc.encode(JSON.stringify({ message: { content: 'A1' } }) + '\n')); + } + }); + return new Response(stream, { status: 200 }); + } + return ndjsonResponse([JSON.stringify({ message: { content: 'B' } }), JSON.stringify({ done: true })]); + }; + const { runtime } = await setupRouter({ + settings: { ...defaultSettings(), model: 'm1' }, + fetchResponses: null + }); + const { startFromSelection } = await import('../src/background/menu-translation.js'); + startFromSelection('first'); + await new Promise((resolve) => setTimeout(resolve, 30)); + startFromSelection('second'); + await new Promise((resolve) => setTimeout(resolve, 60)); + if (controllerA) controllerA.close(); + + const listener = runtime.onMessage.__listener(); + const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); + // The latest request (B) must own the state. + assertEqual(s.state.sourceText, 'second'); + assertEqual(s.state.translatedText, 'B'); + assertTrue(s.state.status === POPUP_STATUSES.COMPLETED || s.state.status === POPUP_STATUSES.STREAMING); + globalThis.fetch = realFetch; + }); + + it('regenerateLast reuses the stored source text with a new requestId', async () => { + const lines = [JSON.stringify({ message: { content: 'OK' } }), JSON.stringify({ done: true })]; + globalThis.fetch = async () => ndjsonResponse(lines); + const { runtime } = await setupRouter({ + settings: { ...defaultSettings(), model: 'm1' }, + fetchResponses: null + }); + const { startFromSelection } = await import('../src/background/menu-translation.js'); + startFromSelection('original text'); + await new Promise((resolve) => setTimeout(resolve, 40)); + const listener = runtime.onMessage.__listener(); + const before = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); + const regen = await listener({ type: RUNTIME_MESSAGES.REGENERATE_LAST }, { id: 'ext' }); + assertEqual(regen.ok, true); + assertTrue(regen.requestId !== before.state.requestId); + await new Promise((resolve) => setTimeout(resolve, 40)); + const after = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); + assertEqual(after.state.sourceText, 'original text'); + assertEqual(after.state.status, POPUP_STATUSES.COMPLETED); + assertEqual(after.state.translatedText, 'OK'); + globalThis.fetch = realFetch; + }); + + it('popup-state port receives live state broadcasts while streaming', async () => { + const lines = [ + JSON.stringify({ message: { content: 'あ' } }), + JSON.stringify({ message: { content: 'い' } }), + JSON.stringify({ done: true }) + ]; + const { runtime } = await setupRouter({ + settings: { ...defaultSettings(), model: 'm1' }, + fetchResponses: async () => ndjsonResponse(lines, [1]) + }); + // Register the popup-state handler (in production index.js registers + // both onConnect listeners; in tests we register explicitly). + const router = await import('../src/background/message-router.js'); + const popupPort = fakePort(); + popupPort.name = 'popup-state'; + const received = []; + popupPort.postMessage = (m) => received.push(m); + router.handlePopupStateConnection(popupPort); + + const { startFromSelection } = await import('../src/background/menu-translation.js'); + startFromSelection('live test'); + await new Promise((resolve) => setTimeout(resolve, 80)); + + // Initial state push + per-chunk broadcasts + final completed broadcast + assertTrue(received.length >= 2, 'should receive initial + live broadcasts'); + const last = received[received.length - 1]; + assertEqual(last.type, 'popup-state'); + assertEqual(last.state.translatedText, 'あい'); + assertTrue( + last.state.status === POPUP_STATUSES.COMPLETED || last.state.status === POPUP_STATUSES.STREAMING, + 'final broadcast should be completed (or streaming for the last chunk)' + ); + globalThis.fetch = realFetch; + }); + + it('handlePopupStateConnection ignores ports with wrong name', async () => { + const { runtime } = await setupRouter({ + settings: { ...defaultSettings(), model: 'm1' }, + fetchResponses: async () => ndjsonResponse([JSON.stringify({ done: true })]) + }); + const router = await import('../src/background/message-router.js'); + const wrongPort = fakePort(); + wrongPort.name = 'translation-stream'; // content-script port, not popup + const received = []; + wrongPort.postMessage = (m) => received.push(m); + // Emit through popup-state handler: it must ignore non-popup ports. + router.handlePopupStateConnection(wrongPort); + await new Promise((resolve) => setTimeout(resolve, 20)); + assertEqual(received.length, 0); + globalThis.fetch = realFetch; + }); + + it('auto-selects the first Ollama model when storage has none (fresh install)', async () => { + // settings.model is '' (defaultSettings) and Ollama responds with a list. + globalThis.fetch = async (url) => { + if (String(url).endsWith('/api/tags')) { + return new Response(JSON.stringify({ models: [{ name: 'first-model' }, { name: 'second-model' }] }), { status: 200 }); + } + return ndjsonResponse([JSON.stringify({ message: { content: 'OK' } }), JSON.stringify({ done: true })]); + }; + const { runtime, storage } = await setupRouter({ + settings: defaultSettings(), // model: '' + fetchResponses: null + }); + // runTranslation resolves the browser API from globalThis.browser when + // no browserApi is passed (production behavior); provide it for tests. + globalThis.browser = { storage, runtime }; + const { startFromSelection } = await import('../src/background/menu-translation.js'); + const started = startFromSelection('fresh install test'); + assertEqual(started.ok, true); + await new Promise((resolve) => setTimeout(resolve, 60)); + const listener = runtime.onMessage.__listener(); + const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); + // The translation must have started with the auto-picked model. + assertEqual(s.state.model, 'first-model'); + assertEqual(s.state.status, POPUP_STATUSES.COMPLETED); + // And the model must be persisted so the settings UI shows it. + const stored = await storage.local.get({ settings: null }); + assertEqual(stored.settings && stored.settings.model, 'first-model'); + globalThis.fetch = realFetch; + }); + + it('reports OLLAMA_UNAVAILABLE when no model configured and Ollama is down', async () => { + globalThis.fetch = async () => { + throw new TypeError('Failed to fetch'); + }; + const { runtime } = await setupRouter({ + settings: defaultSettings(), // model: '' + fetchResponses: null + }); + const { startFromSelection } = await import('../src/background/menu-translation.js'); + startFromSelection('offline test'); + await new Promise((resolve) => setTimeout(resolve, 50)); + const listener = runtime.onMessage.__listener(); + const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); + assertEqual(s.state.status, POPUP_STATUSES.ERROR); + // User-safe message: no raw exception text. + assertFalse(/Failed to fetch/i.test(s.state.error)); + delete globalThis.browser; + globalThis.fetch = realFetch; + }); + + it('error detail is user-safe: URLs stripped, length capped, JSON dropped', async () => { + // Ollama returns a 400 with a JSON body including an internal URL. + globalThis.fetch = async () => + new Response(JSON.stringify({ error: 'bad request see http://localhost:11434/internal for more {huge json' }), { status: 400 }); + const { runtime } = await setupRouter({ + settings: { ...defaultSettings(), model: 'm1' }, + fetchResponses: null + }); + const { startFromSelection } = await import('../src/background/menu-translation.js'); + startFromSelection('error detail test'); + await new Promise((resolve) => setTimeout(resolve, 50)); + const listener = runtime.onMessage.__listener(); + const s = await listener({ type: RUNTIME_MESSAGES.GET_POPUP_STATE }, { id: 'ext' }); + assertEqual(s.state.status, POPUP_STATUSES.ERROR); + assertFalse(/http:\/\//.test(s.state.error)); + delete globalThis.browser; + globalThis.fetch = realFetch; + }); +}); + run();
\ No newline at end of file |
