summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/all-tests.js300
-rw-r--r--tests/ui-smoke-tests.js36
2 files changed, 329 insertions, 7 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
diff --git a/tests/ui-smoke-tests.js b/tests/ui-smoke-tests.js
index 84fe4c4..e88a811 100644
--- a/tests/ui-smoke-tests.js
+++ b/tests/ui-smoke-tests.js
@@ -93,22 +93,44 @@ async function runPageScript(scriptPath, markup, probe) {
describe('popup page script smoke', () => {
it('initializes language options and model select from markup', async () => {
const r = await runPageScript('../src/popup/popup.js', `
- <input type="checkbox" id="enabled-toggle">
- <select id="model-select"></select>
- <button id="reload-models"></button>
- <p id="model-status" hidden></p>
- <select id="language-select"></select>
- <button id="open-options"></button>
+ <header class="popup-header">
+ <h1>Ollama Translations</h1>
+ <nav class="mode-nav">
+ <button id="nav-settings" type="button" class="nav-btn active">設定</button>
+ <button id="nav-translation" type="button" class="nav-btn">翻訳</button>
+ </nav>
+ </header>
+ <section id="translation-mode" class="mode" hidden>
+ <p id="translation-status" class="status"></p>
+ <p id="translation-meta" class="meta" hidden></p>
+ <div id="translation-text" class="translation-text"></div>
+ <p id="translation-error" class="status error" hidden></p>
+ <div class="actions">
+ <button id="copy-translation" type="button" disabled>コピー</button>
+ <button id="regenerate-translation" type="button" disabled>再生成</button>
+ <button id="open-options-from-translation" type="button">設定を開く</button>
+ </div>
+ </section>
+ <section id="settings-mode" class="mode">
+ <input type="checkbox" id="enabled-toggle">
+ <select id="model-select"></select>
+ <button id="reload-models"></button>
+ <p id="model-status" hidden></p>
+ <select id="language-select"></select>
+ <button id="open-options"></button>
+ </section>
`, () => ({
langOptions: document.getElementById('language-select').options.length,
langValue: document.getElementById('language-select').value,
modelOptions: document.getElementById('model-select').options.length,
- firstModel: document.getElementById('model-select').options[0]?.textContent ?? null
+ firstModel: document.getElementById('model-select').options[0]?.textContent ?? null,
+ translationHidden: document.getElementById('translation-mode').hidden
}));
assertEqual(r.langOptions, LANGUAGES.length);
assertEqual(r.langValue, 'ja'); // default target language
assertTrue(r.modelOptions > 0, 'model select should be populated');
assertEqual(r.firstModel, 'fake-model');
+ assertEqual(r.translationHidden, true); // no translation yet: settings mode
});
});