diff --git a/docs/testing/e2e/core.spec.ts b/docs/testing/e2e/core.spec.ts index 1026476..1fe6d06 100644 --- a/docs/testing/e2e/core.spec.ts +++ b/docs/testing/e2e/core.spec.ts @@ -3809,43 +3809,30 @@ test.describe('输入翻译:对方语言的判定链', () => { const DOT = '#pt-host-input-dot .pt-input-dot'; test('@core TC-E2E-126: 源语言是 auto 时,本页翻译过、引擎报告了检测语言 → 优先于页面的语言声明(#660)', async ({ - page, serviceWorker, seedSettings, gotoFixture, + page, mockDeepl, seedSettings, gotoFixture, }) => { // 只用 DeepL:它在响应里报告检测语言(默认引擎 google-web 不报告) await seedSettings({ enginePriority: ['deepl'] }); - await serviceWorker.evaluate(() => chrome.storage.local.set({ 'pt-keys': { deepl: 'e2e:fx' } })); - // DeepL 端点:译文带上目标语言,检测语言恒报日文(页面声明的是 en) - await serviceWorker.evaluate(() => { - const realFetch = self.fetch.bind(self); - (self as any).fetch = async (input: any, init?: any) => { - const url = typeof input === 'string' ? input : input?.url ?? input?.href ?? ''; - if (url === 'https://api-free.deepl.com/v2/translate') { - const body = new URLSearchParams(String(init?.body ?? '')); - const target = body.get('target_lang'); - return new Response( - JSON.stringify({ - translations: body.getAll('text').map((t) => ({ - text: `[DL:${target}] ${t}`, - detected_source_language: 'JA', - })), - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ); - } - return realFetch(input, init); - }; - }); + // DeepL 替身:译文带上目标语言,检测语言恒报日文(页面声明的是 en)。 + // #723:走夹具的描述符,扛得住 SW 重启 + await mockDeepl({ detectedSourceLanguage: 'JA' }); + // #723:先认替身的译文前缀,没经过替身时报错直接指向替身或种子设置, + // 而不是“译文不符” + const viaStub = /^\[DL:/; + const stubLost = '没有经过 DeepL 替身:替身失效,或种子设置被改写'; await gotoFixture('input'); // 整页翻译一次,引擎报告检测语言 JA await translateAndWait(page); + await expect(page.locator('.pt-trans').first(), `整页翻译${stubLost}`).toHaveText(viaStub); // 夹具声明 lang="en",但检测语言优先:译成日文 const box = page.locator('#reply'); await box.click(); await page.keyboard.type('你好世界'); await page.locator(DOT).click(); - await expect(box).toHaveValue('[DL:JA] 你好世界', { timeout: 10_000 }); + await expect(box, `输入翻译${stubLost}`).toHaveValue(viaStub, { timeout: 10_000 }); + await expect(box).toHaveValue('[DL:JA] 你好世界'); }); }); diff --git a/docs/testing/e2e/fixtures.ts b/docs/testing/e2e/fixtures.ts index b47f6ac..32c6f9b 100644 --- a/docs/testing/e2e/fixtures.ts +++ b/docs/testing/e2e/fixtures.ts @@ -4,7 +4,8 @@ * 关键设计: * 1. persistent context + --load-extension 加载扩展 * 2. mockGoogle 在 SW 内 stub fetch(#89:CDP route 对 SW 请求拦截不可靠) - * 3. seedSettings 写入 chrome.storage.sync 并等待生效 + * 3. seedSettings 写入 chrome.storage.sync 并等待生效;交出 service worker + * 之前先等首装按界面语言写入的目标语言落盘(#723) * 4. fixture 页面通过 HTTP 提供(绕开 file:// 的 content script 限制) */ import { test as base, chromium, expect, type Page, type Worker } from '@playwright/test'; @@ -87,6 +88,8 @@ export const test = base.extend< /** 人工响应延迟毫秒,制造在飞窗口(#120) */ delayMs?: number; }) => Promise; + /** DeepL 替身:写入假 key,并装上能扛住 SW 重启的端点替身(#723) */ + mockDeepl: (opts?: { detectedSourceLanguage?: string }) => Promise; seedSettings: (patch: Record) => Promise; gotoFixture: (name: FixtureName) => Promise; }, @@ -138,10 +141,25 @@ export const test = base.extend< ] as any, // ── Service Worker ── + // + // #723:首装时 background 按界面语言推导目标语言,结果不是 zh-CN 就 + // patchSettings 写一次(#469)。上面的 locale 并不能保证首装看到的是 + // zh-CN:它是挂上 worker 之后才覆盖的界面语言,CI 的 Linux 上真实界面 + // 是 en-US,本地也会随机赶在覆盖之前。这次写入先读后写,在 worker 起来 + // 后一两百毫秒落盘,与 seedSettings 交错时合并基底是空存储,种子整份被 + // 覆盖回默认值(enginePriority 回到 google-web,目标语言变成 en)。 + // 所以等 background 报告首装处理落定,再把 worker 交给用例。 serviceWorker: async ({ context }, use) => { const worker = context.serviceWorkers()[0] ?? (await context.waitForEvent('serviceworker', { timeout: 30_000 })); + await expect + .poll(() => worker.evaluate(() => typeof (self as any).ptInstallSettled), { + message: 'background 没有暴露首装落定的信号 ptInstallSettled', + timeout: 10_000, + }) + .toBe('object'); + await worker.evaluate(() => (self as any).ptInstallSettled); await use(worker); }, @@ -184,6 +202,24 @@ export const test = base.extend< }); }, + // ── Mock DeepL ── + // #723:与 mockGoogle 同一套描述符(applyE2EMock 写入 storage,翻译路由 + // 前 ensureE2EMock 自愈重装)。用例自己在 SW 里改写 self.fetch 的替身 + // 随实例消失,请求就打到真实端点。描述符与已有的合并,可与 mockGoogle + // 叠用;只装 DeepL 时 Google 端点同样被替身接住,不会直连真实引擎。 + mockDeepl: async ({ serviceWorker }, use) => { + await use(async ({ detectedSourceLanguage = 'EN' } = {}) => { + await serviceWorker.evaluate(async (detected: string) => { + await chrome.storage.local.set({ 'pt-keys': { deepl: 'e2e:fx' } }); + const cur = (await chrome.storage.local.get('pt-e2e-mock'))['pt-e2e-mock'] ?? {}; + await (self as any).applyE2EMock({ + ...cur, + deepl: { detectedSourceLanguage: detected }, + }); + }, detectedSourceLanguage); + }); + }, + // ── 设置种子 ── seedSettings: async ({ serviceWorker }, use) => { await use(async (patch: Record) => { diff --git a/docs/testing/unit/engines/e2e-mock.test.ts b/docs/testing/unit/engines/e2e-mock.test.ts index a31ae38..79a26ed 100644 --- a/docs/testing/unit/engines/e2e-mock.test.ts +++ b/docs/testing/unit/engines/e2e-mock.test.ts @@ -3,7 +3,7 @@ * * 验证:描述符安装 / 幂等包裹、各类故障模式(failOnce / fail / * failTexts)、echoTargetLang、delayMs、非 Google URL 透传、 - * storage 自愈路径(ensureE2EMock)。 + * storage 自愈路径(ensureE2EMock)、DeepL 端点替身(#723)。 */ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -143,4 +143,37 @@ describe('e2e-mock', () => { expect(body[0][0][0]).toBe('【存】Hello'); expect(realFetch).not.toHaveBeenCalled(); }); + + test('deepl:DeepL 端点命中替身,译文带目标语言、报告检测语言(#723)', async () => { + const { applyE2EMock } = await import('~/src/engines/e2e-mock'); + await applyE2EMock({ deepl: { detectedSourceLanguage: 'JA' } }); + + const resp = await fetch('https://api-free.deepl.com/v2/translate', { + method: 'POST', + body: new URLSearchParams([['text', '你好'], ['text', '世界'], ['target_lang', 'EN-US']]).toString(), + }); + expect(await resp.json()).toEqual({ + translations: [ + { text: '[DL:EN-US] 你好', detected_source_language: 'JA' }, + { text: '[DL:EN-US] 世界', detected_source_language: 'JA' }, + ], + }); + expect(realFetch).not.toHaveBeenCalled(); + }); + + test('deepl:SW 实例被替换后,下一次 ensureE2EMock 从 storage 恢复 DeepL 替身(#723)', async () => { + const { ensureE2EMock, applyE2EMock } = await import('~/src/engines/e2e-mock'); + await applyE2EMock({ deepl: { detectedSourceLanguage: 'JA' } }); + // 模拟 SW 实例被替换:fetch 还原为真实 fetch + delete (self as unknown as { fetch?: unknown }).fetch; + (self as unknown as { fetch: unknown }).fetch = realFetch; + + await ensureE2EMock(); + const resp = await fetch('https://api.deepl.com/v2/translate', { + method: 'POST', + body: new URLSearchParams([['text', 'Hi'], ['target_lang', 'ZH']]).toString(), + }); + expect((await resp.json()).translations[0].text).toBe('[DL:ZH] Hi'); + expect(realFetch).not.toHaveBeenCalled(); + }); }); diff --git a/entrypoints/background.ts b/entrypoints/background.ts index 410a178..e8b0cb8 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -51,6 +51,13 @@ export default defineBackground(() => { (self as any).applyE2EMock = applyE2EMock; // mock 统计探针 —— TC-E2E-47/48 断言一次性故障确实被触发(#91) (self as any).getE2EMockStats = getE2EMockStats; + // 首装处理落定的信号(#723):E2E 夹具等它之后才写种子设置。首装按界面 + // 语言写目标语言(下面的 onInstalled)是先读后写,与种子设置交错时会把 + // 种子整份覆盖回默认值(#469) + let settleInstall!: () => void; + (self as any).ptInstallSettled = new Promise((resolve) => { + settleInstall = resolve; + }); // 右键菜单 + 首次安装引导 chrome.runtime.onInstalled.addListener((details) => { @@ -76,10 +83,13 @@ export default defineBackground(() => { await patchSettings({ to: derived }); } }) - .catch((e) => console.error('[PT] 设置默认语言失败:', e)); + .catch((e) => console.error('[PT] 设置默认语言失败:', e)) + .finally(settleInstall); // 打开欢迎页 chrome.tabs.create({ url: chrome.runtime.getURL('welcome.html') }); + } else { + settleInstall(); } }); diff --git a/package.json b/package.json index ab55eaf..85e0ca3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parallel-translation", - "version": "2.1.38", + "version": "2.1.39", "description": "对照式网页翻译浏览器扩展", "license": "GPL-3.0-or-later", "private": true, diff --git a/src/engines/e2e-mock.ts b/src/engines/e2e-mock.ts index 4fd6f85..9dc94ea 100644 --- a/src/engines/e2e-mock.ts +++ b/src/engines/e2e-mock.ts @@ -18,6 +18,9 @@ // chrome.storage 是唯一跨实例持久的地方。mock 以“描述符 + 按需安装” // 的形式随 SW 生命周期自愈;线上环境从不写入该键,读取为空直接返回, // 无行为变化。 +// +// #723:自带 key 的 DeepL 也走这套描述符。用例在 SW 里直接改写 self.fetch +// 的替身随实例消失,请求打到真实端点,假 key 失败或拿回真实译文。 export interface E2EMockConfig { prefix?: string; @@ -36,8 +39,19 @@ export interface E2EMockConfig { echoTargetLang?: boolean; /** 人工响应延迟(毫秒),制造在飞翻译窗口(#120 TC-E2E-42)。 */ delayMs?: number; + /** + * DeepL 端点替身(#723):译文为 `[DL:] <原文>`, + * 检测语言恒报 detectedSourceLanguage。不设则 DeepL 请求照常直连。 + */ + deepl?: { detectedSourceLanguage: string }; } +/** DeepL 的两个翻译端点(免费 key 与付费 key) */ +const DEEPL_ENDPOINTS = [ + 'https://api-free.deepl.com/v2/translate', + 'https://api.deepl.com/v2/translate', +]; + const STORAGE_KEY = 'pt-e2e-mock'; /** 当前生效的 mock 配置。null = 无 mock(线上行为)。 */ @@ -84,6 +98,19 @@ function installStub(): void { if (!cfg) return realFetch(input, init); const url = typeof input === 'string' ? input : input?.url ?? input?.href ?? ''; + if (cfg.deepl && DEEPL_ENDPOINTS.includes(url)) { + const body = new URLSearchParams(String(init?.body ?? '')); + const target = body.get('target_lang'); + return new Response( + JSON.stringify({ + translations: body.getAll('text').map((t) => ({ + text: `[DL:${target}] ${t}`, + detected_source_language: cfg.deepl!.detectedSourceLanguage, + })), + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + } if (!url.startsWith('https://translate.googleapis.com/')) { return realFetch(input, init); }