Skip to content

Commit 80dc252

Browse files
committed
✅ 新增 v1.4 关键改动的 E2E 覆盖
为 v1.4.0-beta.3 → beta.4 的核心改动补充 Playwright E2E(复用现有两阶段 userScripts fixture,并新增本地 mock server + host-resolver 域名映射,全程不依赖外网): - resource-update: @require/@resource 注入与读取、24h TTL 同 URL 去重、 拉取失败优雅降级、多 require 并发全部加载 (#1193) - gm-xhr-site-access: 跨域 @connect 放行、x-sc-request-marker 经 DNR 剥离、 非 @connect 域名拒绝 (#1477, mv3_utils) - background-script: @background 脚本经 Chrome offscreen 执行并具备 DOM 环境 (#1457) - user-config-yaml: 含 __proto__ 的恶意 @userconfig 被拒、原型链未污染 (#1494) - backup-zip: 备份导出/导入 zip 往返(JSZipp generateAsync/loadAsync)(#1479) - server-fixtures: 共享 fixture + mock server
1 parent 313e464 commit 80dc252

6 files changed

Lines changed: 954 additions & 0 deletions

e2e/background-script.spec.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Page } from "@playwright/test";
2+
import { testWithUserScripts as test, expect } from "./fixtures";
3+
import { installScriptByCode, openOptionsPage } from "./utils";
4+
5+
/**
6+
* 后台脚本(offscreen/sandbox)执行路径回归 (#1457 offscreen 重构)
7+
*
8+
* #1457 把 offscreen 管理重构为 BackgroundEnvManagerBase + Chrome(OffscreenManager) /
9+
* Firefox(EventPageOffscreenManager) 两条分支。Chrome MV3 仍走 chrome.offscreen.createDocument。
10+
* 该重构有回归风险:后台脚本依赖 SW → Offscreen → Sandbox 链路。
11+
*
12+
* 这里用一个 @background 脚本验证 Chrome 路径仍然正常:
13+
* - 后台脚本被启用后能执行(offscreen/sandbox 环境存活)
14+
* - 后台脚本里 GM_setValue 能写入(值经 SW ValueService 持久化到 chrome.storage.local)
15+
* - 后台 sandbox 环境具备 DOM(typeof document === "object"),证明 offscreen DOM 可用
16+
*
17+
* 注意:新安装的后台脚本默认是禁用状态(status=2),需先在列表里启用。
18+
* 观察方式:扫描 chrome.storage.local 中的 value:* 键,找到 data.ran===true 的那条。
19+
*/
20+
21+
const STORAGE_NAME = "e2e_bg";
22+
23+
const backgroundScript = `// ==UserScript==
24+
// @name E2E Background Script
25+
// @namespace https://e2e.test
26+
// @version 1.0.0
27+
// @description background offscreen e2e
28+
// @author E2E
29+
// @background
30+
// @storageName ${STORAGE_NAME}
31+
// @grant GM_setValue
32+
// ==/UserScript==
33+
34+
return new Promise((resolve) => {
35+
try {
36+
GM_setValue("ran", true);
37+
GM_setValue("hasDocument", typeof document === "object");
38+
GM_setValue("hasWindow", typeof window === "object");
39+
} catch (e) {
40+
GM_setValue("err", String(e));
41+
}
42+
resolve();
43+
});
44+
`;
45+
46+
/** 扫描所有 value:* 键,返回第一条 data.ran===true 的 data 对象 */
47+
async function readRanValue(page: Page): Promise<any> {
48+
return page.evaluate(() => {
49+
return new Promise((resolve) => {
50+
chrome.storage.local.get(null, (all) => {
51+
for (const key of Object.keys(all)) {
52+
if (key.startsWith("value:") && all[key]?.data?.ran === true) {
53+
resolve(all[key].data);
54+
return;
55+
}
56+
}
57+
resolve(null);
58+
});
59+
});
60+
});
61+
}
62+
63+
test.describe("Background script offscreen path (#1457)", () => {
64+
test("@background 脚本在 Chrome offscreen 路径下执行并能持久化 GM_setValue(含 DOM 环境)", async ({
65+
context,
66+
extensionId,
67+
}) => {
68+
await installScriptByCode(context, extensionId, backgroundScript);
69+
70+
const page = await openOptionsPage(context, extensionId);
71+
try {
72+
// 后台脚本默认禁用,先在列表里启用它
73+
const scriptSwitch = page.locator(".arco-switch").first();
74+
await expect(scriptSwitch).toBeVisible({ timeout: 10_000 });
75+
if ((await scriptSwitch.getAttribute("aria-checked")) !== "true") {
76+
await scriptSwitch.evaluate((el) => (el as HTMLElement).click());
77+
await expect(scriptSwitch).toHaveAttribute("aria-checked", "true", { timeout: 10_000 });
78+
}
79+
80+
// 启用后后台脚本异步执行,轮询存储直到写入或超时
81+
let data: any = null;
82+
const start = Date.now();
83+
while (Date.now() - start < 20_000) {
84+
data = await readRanValue(page);
85+
if (data?.ran === true) break;
86+
await page.waitForTimeout(500);
87+
}
88+
89+
expect(data, "未读到后台脚本写入的 value:*(data.ran)").not.toBeNull();
90+
expect(data.err, `后台脚本抛错: ${data?.err}`).toBeUndefined();
91+
expect(data.ran).toBe(true);
92+
// offscreen/sandbox 环境应具备 DOM 与 window
93+
expect(data.hasDocument).toBe(true);
94+
expect(data.hasWindow).toBe(true);
95+
} finally {
96+
await page.close();
97+
}
98+
});
99+
});

e2e/backup-zip.spec.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import fs from "fs";
2+
import type { BrowserContext, Page } from "@playwright/test";
3+
import { test, expect } from "./fixtures";
4+
import { installScriptByCode, openOptionsPage } from "./utils";
5+
6+
/**
7+
* 本地备份导出/导入(JSZip → JSZipp 替换, #1479)
8+
*
9+
* #1479 用 web-jszipp 替换 jszip 处理 ZIP。备份导出(createJSZip → generateAsync)与
10+
* 导入(loadAsyncJSZip → openZip)都走 jszip-x。本 spec 做一次端到端往返,覆盖两端:
11+
* - 工具页“导出文件”生成 .zip 备份(generateAsync);读取后断言是合法 ZIP(PK 魔数)
12+
* - 把该 zip 喂回“导入文件”,导入页用 loadAsyncJSZip 解析后应列出被备份的脚本名
13+
*
14+
* 注意:“导入文件”按钮通过 window.open 打开 import.html 弹窗;该弹窗在 headless 下
15+
* 初始化消息通道会抛 onMessage 错误(与 #1479 无关、与弹窗窗口环境有关)。改用同一个
16+
* import.html?uuid=... URL 以普通标签页(goto)打开即可正常渲染——这并不影响对
17+
* loadAsyncJSZip 解析能力的验证。
18+
*/
19+
20+
const SCRIPT_NAME = "Backup RoundTrip E2E";
21+
const script = `// ==UserScript==
22+
// @name ${SCRIPT_NAME}
23+
// @namespace https://e2e.test
24+
// @version 1.0.0
25+
// @description backup zip e2e
26+
// @author E2E
27+
// @match http://example.com/*
28+
// @grant none
29+
// ==/UserScript==
30+
(function(){})();
31+
`;
32+
33+
/** 轮询 SW 的 chrome.downloads,拿到刚导出的 blob 备份文件在磁盘上的路径 */
34+
async function waitForExportedZipPath(context: BrowserContext, timeoutMs = 15_000): Promise<string> {
35+
const sw = context.serviceWorkers()[0];
36+
const start = Date.now();
37+
while (Date.now() - start < timeoutMs) {
38+
const filename: string | null = await sw.evaluate(
39+
() =>
40+
new Promise((resolve) => {
41+
chrome.downloads.search({ limit: 5, orderBy: ["-startTime"] }, (items) => {
42+
const hit = items.find((i) => (i.url || "").startsWith("blob:chrome-extension") && i.state === "complete");
43+
resolve(hit ? hit.filename : null);
44+
});
45+
})
46+
);
47+
if (filename && fs.existsSync(filename)) return filename;
48+
await new Promise((r) => setTimeout(r, 300));
49+
}
50+
throw new Error("timeout waiting for exported backup zip download");
51+
}
52+
53+
test.describe("Backup zip export/import round-trip (#1479)", () => {
54+
test("导出备份生成合法 zip,再导入能被 loadAsyncJSZip 解析并列出脚本", async ({ context, extensionId }) => {
55+
await installScriptByCode(context, extensionId, script);
56+
57+
const page = await openOptionsPage(context, extensionId);
58+
await page.goto(`chrome-extension://${extensionId}/src/options.html#/tools`);
59+
await page.waitForLoadState("domcontentloaded");
60+
const exportBtn = page.locator("button", { hasText: /|Export File/ }).first();
61+
await exportBtn.waitFor({ timeout: 10_000 });
62+
63+
// 1) 导出
64+
await exportBtn.click();
65+
const zipPath = await waitForExportedZipPath(context);
66+
67+
// 2) 断言导出的是合法 ZIP(PK 魔数),generateAsync 工作正常
68+
const buf = fs.readFileSync(zipPath);
69+
expect(buf.length).toBeGreaterThan(0);
70+
expect(buf[0]).toBe(0x50); // 'P'
71+
expect(buf[1]).toBe(0x4b); // 'K'
72+
73+
// 3) 导入:点击“导入文件”→ 触发隐藏 file input 的 click(filechooser),选中刚导出的 zip,
74+
// openImportWindow 会把文件写入 cache 并 window.open 导入页(弹窗),从弹窗 URL 取 uuid
75+
page.on("filechooser", (fc) => fc.setFiles(zipPath).catch(() => {}));
76+
const popupPromise = context.waitForEvent("page", { timeout: 15_000 });
77+
await page
78+
.locator("button", { hasText: /|Import File/ })
79+
.first()
80+
.click();
81+
const popup = await popupPromise;
82+
await popup.waitForLoadState("domcontentloaded").catch(() => {});
83+
const importUrl = popup.url();
84+
expect(importUrl).toContain("import.html?uuid=");
85+
await popup.close().catch(() => {});
86+
87+
// 4) 以普通标签页打开同一 import URL,loadAsyncJSZip 解析 cache 中的 zip 后应列出脚本名
88+
const importPage: Page = await context.newPage();
89+
await importPage.goto(importUrl);
90+
await importPage.waitForLoadState("domcontentloaded");
91+
await expect(importPage.locator("body")).toContainText(SCRIPT_NAME, { timeout: 15_000 });
92+
93+
await importPage.close();
94+
await page.close();
95+
});
96+
});

e2e/gm-xhr-site-access.spec.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import type { BrowserContext, Page } from "@playwright/test";
2+
import { test, expect, startMockServer, type MockServer } from "./server-fixtures";
3+
import { installScriptByCode } from "./utils";
4+
5+
/**
6+
* GM_xmlhttpRequest 跨域 / 站点访问权限处理 (#1477) 与 MV3 DNR 标记 (mv3_utils)
7+
*
8+
* #1477 修复“缺少站点访问权限时跨域请求异常”。其配套的 mv3_utils 在 Chrome 上用
9+
* ChromiumHeaderMarkerLinker:给后台发起的请求加 x-sc-request-marker 头用于把响应关联回
10+
* content 端,并通过 DNR 规则在真正发出前把该标记头删掉,因此目标服务器收不到它。
11+
*
12+
* 端到端可观察点(均不依赖外网,靠 host-resolver 映射到本地 mock server):
13+
* - 声明了 @connect 的跨域 GET 能成功(请求不再异常)
14+
* - 自定义请求头能透传到服务器;而 x-sc-request-marker 标记头被 DNR 剥离(服务器收不到)
15+
* - 未在 @connect 列表中的域名被拒绝(onerror)
16+
*
17+
* 关于 extension-site-access 确认弹窗路径(confirmExtensionSiteAccess):
18+
* 它只在“扩展缺少目标站点访问权限”时触发。本扩展 host_permissions 为 <all_urls>,
19+
* 加载的未打包扩展默认即拥有站点访问权限,故默认不会触发。试图通过 developerPrivate
20+
* 把 hostAccess 改为 ON_CLICK 来制造“缺权限”状态时,会连带导致脚本在其宿主页面也无法注入
21+
* (扩展对宿主页同样失去访问权),脚本根本不运行、也就不会发起请求——因此该弹窗分支无法在
22+
* headless 下干净复现。其分支逻辑由 SW 侧逻辑/单测覆盖;这里用更可靠的方式覆盖 #1477 的核心:
23+
* 跨域请求成功 + DNR 标记剥离 + 非 @connect 拒绝。
24+
*/
25+
26+
const SENTINEL = "[XHR_E2E]";
27+
28+
async function runXhr(
29+
context: BrowserContext,
30+
targetPageUrl: string,
31+
timeoutMs = 15_000
32+
): Promise<{ data: any; logs: string[] }> {
33+
const page: Page = await context.newPage();
34+
const logs: string[] = [];
35+
let resolved: any = null;
36+
const ready = new Promise<void>((resolve) => {
37+
page.on("console", (msg) => {
38+
const text = msg.text();
39+
logs.push(text);
40+
const idx = text.indexOf(SENTINEL);
41+
if (idx >= 0) {
42+
try {
43+
resolved = JSON.parse(text.slice(idx + SENTINEL.length));
44+
resolve();
45+
} catch {
46+
/* ignore */
47+
}
48+
}
49+
});
50+
});
51+
52+
const start = Date.now();
53+
await page.goto(targetPageUrl, { waitUntil: "domcontentloaded" });
54+
while (!resolved && Date.now() - start < timeoutMs) {
55+
const got = await Promise.race([ready.then(() => true), page.waitForTimeout(1500).then(() => false)]);
56+
if (got || resolved) break;
57+
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
58+
}
59+
await page.close();
60+
if (!resolved) throw new Error(`no sentinel from ${targetPageUrl}\nlogs:\n${logs.join("\n")}`);
61+
return { data: resolved, logs };
62+
}
63+
64+
function xhrScript(opts: {
65+
name: string;
66+
matchHost: string;
67+
connect: string[];
68+
url: string;
69+
port: number;
70+
headers?: Record<string, string>;
71+
}): string {
72+
const connectLines = opts.connect.map((c) => `// @connect ${c}`).join("\n");
73+
return `// ==UserScript==
74+
// @name ${opts.name}
75+
// @namespace https://e2e.test
76+
// @version 1.0.0
77+
// @description gm xhr e2e
78+
// @match http://${opts.matchHost}:${opts.port}/*
79+
${connectLines}
80+
// @grant GM_xmlhttpRequest
81+
// @noframes
82+
// ==/UserScript==
83+
84+
(function () {
85+
function emit(o) { console.log("${SENTINEL}" + JSON.stringify(o)); }
86+
try {
87+
GM_xmlhttpRequest({
88+
method: "GET",
89+
url: ${JSON.stringify(opts.url)},
90+
headers: ${JSON.stringify(opts.headers || {})},
91+
onload: function (r) {
92+
var parsed = null;
93+
try { parsed = JSON.parse(r.responseText); } catch (e) {}
94+
emit({ phase: "load", status: r.status, ok: parsed && parsed.ok === true, method: parsed && parsed.method });
95+
},
96+
onerror: function (e) {
97+
emit({ phase: "error", error: (e && (e.error || e.statusText || e.message)) || String(e) });
98+
},
99+
});
100+
} catch (e) {
101+
emit({ phase: "throw", error: String(e) });
102+
}
103+
})();
104+
`;
105+
}
106+
107+
test.describe("GM_xmlhttpRequest site-access & DNR marker (#1477)", () => {
108+
let server: MockServer;
109+
110+
test.beforeEach(async () => {
111+
server = await startMockServer();
112+
});
113+
test.afterEach(async () => {
114+
await server.close();
115+
});
116+
117+
test("声明 @connect 的跨域 GET 请求成功", async ({ context, extensionId }) => {
118+
const code = xhrScript({
119+
name: "XHR connect ok",
120+
matchHost: "sitea.test",
121+
connect: ["xhrtarget.test"],
122+
url: server.url("xhrtarget.test", "/xhr"),
123+
port: server.port,
124+
});
125+
await installScriptByCode(context, extensionId, code);
126+
127+
const { data, logs } = await runXhr(context, server.url("sitea.test", "/page"));
128+
expect(data.phase, `期望 load,实际: ${JSON.stringify(data)}\n${logs.join("\n")}`).toBe("load");
129+
expect(data.status).toBe(200);
130+
expect(data.ok).toBe(true);
131+
expect(data.method).toBe("GET");
132+
});
133+
134+
test("自定义请求头透传到服务器,x-sc-request-marker 标记头被 DNR 剥离", async ({ context, extensionId }) => {
135+
const code = xhrScript({
136+
name: "XHR header marker",
137+
matchHost: "sitea.test",
138+
connect: ["xhrtarget.test"],
139+
url: server.url("xhrtarget.test", "/xhr"),
140+
port: server.port,
141+
headers: { "X-Custom-E2e": "hello-123" },
142+
});
143+
await installScriptByCode(context, extensionId, code);
144+
145+
const { data } = await runXhr(context, server.url("sitea.test", "/page"));
146+
expect(data.phase).toBe("load");
147+
expect(data.status).toBe(200);
148+
149+
// 以服务器实际收到的请求为准(DNR 在网络层剥离标记头)
150+
const xhrReq = server.requestLog.find((r) => r.pathname === "/xhr");
151+
expect(xhrReq, "服务器未收到 /xhr 请求").toBeTruthy();
152+
const headerKeys = Object.keys(xhrReq!.headers).map((k) => k.toLowerCase());
153+
expect(headerKeys, "自定义头未透传").toContain("x-custom-e2e");
154+
expect(xhrReq!.headers["x-custom-e2e"]).toBe("hello-123");
155+
expect(headerKeys, "x-sc-request-marker 不应到达服务器").not.toContain("x-sc-request-marker");
156+
});
157+
158+
test("请求未在 @connect 列表中的域名被拒绝(onerror)", async ({ context, extensionId }) => {
159+
const code = xhrScript({
160+
name: "XHR connect reject",
161+
matchHost: "sitea.test",
162+
connect: ["xhrtarget.test"], // 列了 connect,但请求另一个域名
163+
url: server.url("noconnect.test", "/xhr"),
164+
port: server.port,
165+
});
166+
await installScriptByCode(context, extensionId, code);
167+
168+
const { data } = await runXhr(context, server.url("sitea.test", "/page"));
169+
expect(data.phase, `期望 error,实际: ${JSON.stringify(data)}`).toBe("error");
170+
// 服务器不应收到该被拒绝域名的请求
171+
expect(server.requestLog.find((r) => r.host === "noconnect.test")).toBeFalsy();
172+
});
173+
});

0 commit comments

Comments
 (0)