|
| 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