Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 11 additions & 24 deletions docs/testing/e2e/core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] 你好世界');
});
});

Expand Down
38 changes: 37 additions & 1 deletion docs/testing/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -87,6 +88,8 @@ export const test = base.extend<
/** 人工响应延迟毫秒,制造在飞窗口(#120) */
delayMs?: number;
}) => Promise<void>;
/** DeepL 替身:写入假 key,并装上能扛住 SW 重启的端点替身(#723) */
mockDeepl: (opts?: { detectedSourceLanguage?: string }) => Promise<void>;
seedSettings: (patch: Record<string, unknown>) => Promise<void>;
gotoFixture: (name: FixtureName) => Promise<Page>;
},
Expand Down Expand Up @@ -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);
},

Expand Down Expand Up @@ -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<string, unknown>) => {
Expand Down
35 changes: 34 additions & 1 deletion docs/testing/unit/engines/e2e-mock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
});
});
12 changes: 11 additions & 1 deletion entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => {
settleInstall = resolve;
});

// 右键菜单 + 首次安装引导
chrome.runtime.onInstalled.addListener((details) => {
Expand All @@ -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();
}
});

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "parallel-translation",
"version": "2.1.38",
"version": "2.1.39",
"description": "对照式网页翻译浏览器扩展",
"license": "GPL-3.0-or-later",
"private": true,
Expand Down
27 changes: 27 additions & 0 deletions src/engines/e2e-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
// chrome.storage 是唯一跨实例持久的地方。mock 以“描述符 + 按需安装”
// 的形式随 SW 生命周期自愈;线上环境从不写入该键,读取为空直接返回,
// 无行为变化。
//
// #723:自带 key 的 DeepL 也走这套描述符。用例在 SW 里直接改写 self.fetch
// 的替身随实例消失,请求打到真实端点,假 key 失败或拿回真实译文。

export interface E2EMockConfig {
prefix?: string;
Expand All @@ -36,8 +39,19 @@ export interface E2EMockConfig {
echoTargetLang?: boolean;
/** 人工响应延迟(毫秒),制造在飞翻译窗口(#120 TC-E2E-42)。 */
delayMs?: number;
/**
* DeepL 端点替身(#723):译文为 `[DL:<target_lang>] <原文>`,
* 检测语言恒报 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(线上行为)。 */
Expand Down Expand Up @@ -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);
}
Expand Down
Loading