summaryrefslogtreecommitdiff
path: root/tests/all-tests.js
diff options
context:
space:
mode:
Diffstat (limited to 'tests/all-tests.js')
-rw-r--r--tests/all-tests.js300
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