From b067531088984bddecce3d11eeaa761671284311 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:35:17 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=85=20=E6=B7=BB=E5=8A=A0=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=B7=AF=E7=94=B1=E4=B8=8E=20pageLoad=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E5=9B=9E=E5=BD=92=E6=8A=A4=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .husky/pre-push | 9 ++ e2e/runtime-bootstrap.spec.ts | 53 ++++++++ package.json | 3 + packages/message/server.test.ts | 50 ++++++++ packages/message/server.ts | 55 +++++---- .../content/page_load_contract.test.ts | 59 +++++++++ src/app/service/content/page_load_contract.ts | 75 ++++++++++++ src/app/service/content/script_runtime.ts | 15 ++- src/app/service/content/utils.test.ts | 9 ++ src/app/service/content/utils.ts | 26 ++-- .../service/service_worker/runtime.test.ts | 114 +++++++++++++++++- 11 files changed, 419 insertions(+), 49 deletions(-) create mode 100755 .husky/pre-push create mode 100644 e2e/runtime-bootstrap.spec.ts create mode 100644 src/app/service/content/page_load_contract.test.ts create mode 100644 src/app/service/content/page_load_contract.ts diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000..f8d45b5cb --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,9 @@ +#!/bin/sh + +if [ "$SKIP_RUNTIME_GUARD" = "1" ]; then + echo "⏭ SKIP_RUNTIME_GUARD=1, skipping runtime guard" + exit 0 +fi + +echo "🔍 Running runtime guard before push..." +exec pnpm run guard:runtime diff --git a/e2e/runtime-bootstrap.spec.ts b/e2e/runtime-bootstrap.spec.ts new file mode 100644 index 000000000..68c2f07ce --- /dev/null +++ b/e2e/runtime-bootstrap.spec.ts @@ -0,0 +1,53 @@ +import { testWithUserScripts as test, expect } from "./fixtures"; +import { installScriptByCode } from "./utils"; +import type { Worker } from "@playwright/test"; + +const TARGET_URL = "http://runtime-bootstrap.test/"; + +const RUNTIME_SMOKE_SCRIPT = `// ==UserScript== +// @name Runtime bootstrap smoke +// @namespace https://e2e.test/runtime-bootstrap +// @version 1.0.0 +// @match ${TARGET_URL}* +// @grant GM_info +// @inject-into page +// ==/UserScript== + +document.documentElement.setAttribute( + "data-scriptcat-runtime-smoke", + typeof GM_info === "object" && GM_info?.script?.name === "Runtime bootstrap smoke" ? "ok" : "bad" +); +`; + +test.describe("runtime bootstrap smoke", () => { + test("executes one page userscript within the local fail-fast budget", async ({ context, extensionId }) => { + test.setTimeout(60_000); + await context.route(`${TARGET_URL}**`, (route) => + route.fulfill({ status: 200, contentType: "text/html", body: "" }) + ); + + const serviceWorkerLogs: string[] = []; + const attachServiceWorker = (worker: Worker) => { + worker.on("console", (message) => serviceWorkerLogs.push(message.text())); + }; + for (const worker of context.serviceWorkers()) attachServiceWorker(worker); + context.on("serviceworker", attachServiceWorker); + + await installScriptByCode(context, extensionId, RUNTIME_SMOKE_SCRIPT); + + const page = await context.newPage(); + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + try { + await page.goto(TARGET_URL, { waitUntil: "domcontentloaded" }); + await expect(page.locator("html")).toHaveAttribute("data-scriptcat-runtime-smoke", "ok", { timeout: 5_000 }); + } catch (error) { + console.error("[runtime-smoke] page errors:", pageErrors); + console.error("[runtime-smoke] service-worker console:", serviceWorkerLogs); + throw error; + } finally { + await page.close(); + context.off("serviceworker", attachServiceWorker); + } + }); +}); diff --git a/package.json b/package.json index 1bd92414a..6f612c6c5 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "coverage:ci": "vitest run --coverage --silent --reporter=default --reporter.default.summary=false", "test:e2e:install": "pnpm exec playwright install chromium", "test:e2e": "pnpm exec playwright test", + "test:runtime-contract": "pnpm exec vitest run --no-coverage packages/message/server.test.ts src/app/service/content/page_load_contract.test.ts src/app/service/content/utils.test.ts src/app/service/service_worker/runtime.test.ts", + "test:e2e:runtime": "pnpm exec playwright test e2e/runtime-bootstrap.spec.ts --workers=1 --retries=0", + "guard:runtime": "pnpm run typecheck && pnpm run test:runtime-contract && pnpm run build && pnpm run test:e2e:runtime", "test:e2e:ui": "pnpm exec playwright test --ui", "validate:yaml": "node ./scripts/validate-yaml.mjs", "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all", diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index b7b1ff007..fae816f0a 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -36,6 +36,28 @@ afterEach(() => { describe("Server", () => { describe("基本功能测试 1", () => { + it("应该要求显式替换已经注册的消息处理器", async () => { + const first = vi.fn().mockReturnValue("first"); + const second = vi.fn().mockReturnValue("second"); + server.on("handler", first); + + expect(() => server.on("handler", second)).toThrow("duplicate message handler: api/handler"); + + server.replace("handler", second); + + const response = await client.sendMessage({ action: "api/handler", data: {} }); + + expect(response.data).toBe("second"); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledOnce(); + }); + + it("应该拒绝替换未注册的消息处理器", () => { + expect(() => server.replace("missing", vi.fn())).toThrow( + "cannot replace unregistered message handler: api/missing" + ); + }); + it.concurrent("应该能够注册和调用 API", async () => { const mockHandler = vi.fn().mockResolvedValue("test response"); @@ -282,6 +304,34 @@ describe("Server", () => { expect(handler).toHaveBeenCalledTimes(1); }); + it("Group 替换处理器时应该保留中间件并要求显式 replace", async () => { + const executionOrder: string[] = []; + const middleware = vi.fn(async (_params: any, _con: any, next: any) => { + executionOrder.push("middleware"); + return await next(); + }); + const group = server.group("api", middleware); + const first = vi.fn(() => { + executionOrder.push("first"); + return "first"; + }); + const second = vi.fn(() => { + executionOrder.push("second"); + return "second"; + }); + + group.on("replace-test", first); + expect(() => group.on("replace-test", second)).toThrow("duplicate message handler: api/api/replace-test"); + + group.replace("replace-test", second); + const response = await client.sendMessage({ action: "api/api/replace-test", data: {} }); + + expect(response.data).toBe("second"); + expect(executionOrder).toEqual(["middleware", "second"]); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledOnce(); + }); + it("子 Group 应该继承父 Group 的中间件", async () => { const middlewareOrder: string[] = []; diff --git a/packages/message/server.ts b/packages/message/server.ts index 3df109f80..94a2a958f 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -132,7 +132,7 @@ export class Server { private logger = LoggerCore.getInstance().logger({ service: "messageServer" }); constructor( - prefix: string, + private readonly prefix: string, msgReceiver: Message | Message[], private enableConnect: boolean = true ) { @@ -167,6 +167,16 @@ export class Server { } on(name: string, func: ApiFunction) { + if (this.apiFunctionMap.has(name)) { + throw new Error(`duplicate message handler: ${this.prefix}/${name}`); + } + this.apiFunctionMap.set(name, func); + } + + replace(name: string, func: ApiFunction) { + if (!this.apiFunctionMap.has(name)) { + throw new Error(`cannot replace unregistered message handler: ${this.prefix}/${name}`); + } this.apiFunctionMap.set(name, func); } @@ -260,30 +270,29 @@ export class Group { return newGroup; } - on(name: string, func: ApiFunction) { - const fullName = `${this.name}${name}`; + private wrap(func: ApiFunction): ApiFunction { + if (this.middlewares.length === 0) return func; + return async (params: any, con: IGetSender) => { + let index = 0; - if (this.middlewares.length === 0) { - // 没有中间件,直接注册 - this.server.on(fullName, func); - } else { - // 有中间件,需要包装处理函数 - this.server.on(fullName, async (params: any, con: IGetSender) => { - let index = 0; - - const next = async (): Promise => { - if (index < this.middlewares.length) { - const middleware = this.middlewares[index++]; - return await middleware(params, con, next); - } else { - // 所有中间件都执行完毕,执行最终的处理函数 - return await func(params, con); - } - }; + const next = async (): Promise => { + if (index < this.middlewares.length) { + const middleware = this.middlewares[index++]; + return await middleware(params, con, next); + } + return await func(params, con); + }; - return await next(); - }); - } + return await next(); + }; + } + + on(name: string, func: ApiFunction) { + this.server.on(`${this.name}${name}`, this.wrap(func)); + } + + replace(name: string, func: ApiFunction) { + this.server.replace(`${this.name}${name}`, this.wrap(func)); } } diff --git a/src/app/service/content/page_load_contract.test.ts b/src/app/service/content/page_load_contract.test.ts new file mode 100644 index 000000000..bcf356640 --- /dev/null +++ b/src/app/service/content/page_load_contract.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; +import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; +import { + PAGE_LOAD_SCRIPT_REQUIRED_KEYS, + hasValidPageLoadScriptShape, + pickPageLoadScriptFields, +} from "./page_load_contract"; + +const createScriptLoadInfo = (): ScriptLoadInfo => ({ + uuid: "script", + name: "Script", + namespace: "runtime-guard", + metadata: {}, + type: SCRIPT_TYPE_NORMAL, + status: SCRIPT_STATUS_ENABLE, + sort: 0, + runStatus: "complete", + createtime: 1, + checktime: 1, + code: "console.log('source')", + value: {}, + flag: "script-flag", + resource: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", +}); + +describe("page-load contract", () => { + it("allows optional wire fields to be absent", () => { + const requiredOnly = Object.fromEntries(PAGE_LOAD_SCRIPT_REQUIRED_KEYS.map((key) => [key, key])); + + expect(hasValidPageLoadScriptShape(requiredOnly)).toBe(true); + }); + + it("projects only contract fields without claiming to validate unknown values", () => { + const source = createScriptLoadInfo() as ScriptLoadInfo & { originalUrlPatterns?: unknown[] }; + source.originalUrlPatterns = []; + const resource = {} as TScriptInfo["resource"]; + const requireCssResource = {} as NonNullable; + + const picked = pickPageLoadScriptFields(source, { + resource, + requireCssResource, + code: "", + }); + + expect(Object.hasOwn(picked, "originalUrlPatterns")).toBe(false); + expect(hasValidPageLoadScriptShape(picked)).toBe(true); + }); + + it("rejects unexpected top-level fields", () => { + const value: Record = Object.fromEntries(PAGE_LOAD_SCRIPT_REQUIRED_KEYS.map((key) => [key, key])); + value.unexpected = true; + + expect(hasValidPageLoadScriptShape(value)).toBe(false); + }); +}); diff --git a/src/app/service/content/page_load_contract.ts b/src/app/service/content/page_load_contract.ts new file mode 100644 index 000000000..6053fc680 --- /dev/null +++ b/src/app/service/content/page_load_contract.ts @@ -0,0 +1,75 @@ +import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; + +export const PAGE_LOAD_SCRIPT_REQUIRED_KEYS = [ + "uuid", + "name", + "namespace", + "metadata", + "createtime", + "checktime", + "code", + "value", + "flag", + "resource", + "metadataStr", + "userConfigStr", +] as const; + +export const PAGE_LOAD_SCRIPT_OPTIONAL_KEYS = [ + "author", + "checkUpdate", + "checkUpdateUrl", + "downloadUrl", + "config", + "updatetime", + "requireCssResource", + "userConfig", + "scriptUrlPatterns", +] as const; + +type PageLoadScriptRequiredKey = (typeof PAGE_LOAD_SCRIPT_REQUIRED_KEYS)[number]; +type PageLoadScriptOptionalKey = (typeof PAGE_LOAD_SCRIPT_OPTIONAL_KEYS)[number]; + +export type PageLoadScriptInfo = Pick & + Partial>; + +const PAGE_LOAD_SCRIPT_ALLOWED_KEYS = new Set([ + ...PAGE_LOAD_SCRIPT_REQUIRED_KEYS, + ...PAGE_LOAD_SCRIPT_OPTIONAL_KEYS, +]); + +type PageLoadScriptFieldOverrides = Pick; + +/** + * Verifies only the pageLoad DTO top-level key contract. + * + * This is intentionally a boolean shape check rather than a TypeScript type guard: it prevents producer/consumer + * field drift, but it does not claim that unknown cross-context values have been fully validated as TScriptInfo. + */ +export const hasValidPageLoadScriptShape = (value: unknown): boolean => { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return ( + PAGE_LOAD_SCRIPT_REQUIRED_KEYS.every((key) => Object.hasOwn(record, key)) && + Object.keys(record).every((key) => PAGE_LOAD_SCRIPT_ALLOWED_KEYS.has(key)) + ); +}; + +/** + * Projects the trusted service-worker ScriptLoadInfo onto the pageLoad wire contract. + * Internal producer fields are opt-in: adding a field to ScriptLoadInfo cannot expose it to the page bridge by spread. + */ +export const pickPageLoadScriptFields = ( + source: ScriptLoadInfo, + overrides: PageLoadScriptFieldOverrides +): PageLoadScriptInfo => { + const scriptInfo: Record = {}; + const sourceRecord = source as unknown as Record; + for (const key of [...PAGE_LOAD_SCRIPT_REQUIRED_KEYS, ...PAGE_LOAD_SCRIPT_OPTIONAL_KEYS]) { + if (Object.hasOwn(sourceRecord, key)) scriptInfo[key] = sourceRecord[key]; + } + scriptInfo.resource = overrides.resource; + scriptInfo.requireCssResource = overrides.requireCssResource; + scriptInfo.code = overrides.code; + return scriptInfo as PageLoadScriptInfo; +}; diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 817dd2a23..f2421abba 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -8,6 +8,18 @@ import type { ScriptEnvTag } from "@Packages/message/consts"; import { onInjectPageLoaded } from "./external"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import { type TExtensionEnv } from "../extension/extension_env"; +import { hasValidPageLoadScriptShape } from "./page_load_contract"; + +const hasValidPageLoadDataShape = (value: unknown): boolean => { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return ( + Array.isArray(record.scripts) && + record.scripts.every(hasValidPageLoadScriptShape) && + record.envInfo !== null && + typeof record.envInfo === "object" + ); +}; export class ScriptRuntime { constructor( @@ -64,7 +76,8 @@ export class ScriptRuntime { }); this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { - // 监听事件 + // Keep the existing transport value contract, while rejecting producer/consumer field drift before execution. + if (!hasValidPageLoadDataShape(data)) return; this.startScripts(data.scripts, data.envInfo); }); diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 837728eec..bf421514f 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -481,6 +481,15 @@ describe("utils", () => { contentType: "text/plain", }); }); + + it("does not expose service-worker URL-pattern bookkeeping to the page bridge", () => { + const script = createScript({}, []) as ScriptLoadInfo & { originalUrlPatterns?: unknown }; + script.originalUrlPatterns = []; + + const trimmed = trimScriptInfo(script); + + expect(Object.hasOwn(trimmed, "originalUrlPatterns")).toBe(false); + }); }); describe("compileScript", () => { diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 64181c489..1c64f7edd 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -7,6 +7,7 @@ import { ScriptEnvTag } from "@Packages/message/consts"; import { embeddedPatternCheckerString, type EmbeddedURLRuleEntry, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; import { parseResourceDeclaration } from "@App/pkg/utils/resource"; import { getGrantCandidates } from "./gm_api/grant"; +import { pickPageLoadScriptFields } from "./page_load_contract"; export type CompileScriptCodeResource = { name: string; @@ -215,25 +216,12 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { } } // --- 处理 resource --- - // --- 处理 scriptInfo --- - const scriptInfo = { ...script, resource, requireCssResource, code: "" } as TScriptInfo; - // 删除其他不需要注入的 script 信息 - delete scriptInfo.originalMetadata; - delete scriptInfo.selfMetadata; - delete scriptInfo.lastruntime; - delete scriptInfo.nextruntime; - delete scriptInfo.ignoreVersion; // UserScript 里面不需要知道用户有没有在更新时忽略 - delete scriptInfo.sort; // UserScript 里面不需要知道用户如何 sort - delete scriptInfo.error; - delete scriptInfo.resourceByType; - delete scriptInfo.subscribeUrl; // UserScript 里面不需要知道用户从何处订阅 - delete scriptInfo.originDomain; // 脚本来源域名 - delete scriptInfo.origin; // 脚本来源 - delete scriptInfo.runStatus; // 前台脚本不用 - delete scriptInfo.type; // 脚本类型总是普通脚本 - delete scriptInfo.status; // 脚本状态总是启用 - // --- 处理 scriptInfo --- - return scriptInfo; + // pageLoad 跨上下文只允许显式列出的字段,新增内部字段不会自动进入页面桥。 + return pickPageLoadScriptFields(script, { + resource, + requireCssResource, + code: "", + }); }; /** diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index fc272def4..1887ca87b 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -2,7 +2,7 @@ import { initTestEnv } from "@Tests/utils"; import { RuntimeService } from "./runtime"; import { vi, describe, it, expect, beforeEach, afterEach, type MockedFunction } from "vitest"; import { randomUUID } from "crypto"; -import type { Script, ScriptRunResource } from "@App/app/repo/scripts"; +import type { Script, ScriptLoadInfo, ScriptRunResource } from "@App/app/repo/scripts"; import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE, @@ -11,7 +11,7 @@ import { } from "@App/app/repo/scripts"; import { buildScriptRunResourceBasic, getCombinedMeta, scriptURLPatternResults } from "./utils"; import type { SystemConfig } from "@App/pkg/config/config"; -import { SenderRuntime, type Group } from "@Packages/message/server"; +import { SenderRuntime, type Group, type Server } from "@Packages/message/server"; import type { ServiceWorkerMessageSend, WindowMessageBody } from "@Packages/message/window_message"; import type { IMessageQueue } from "@Packages/message/message_queue"; import type { ValueService } from "./value"; @@ -22,6 +22,12 @@ import { LocalStorageDAO } from "@App/app/repo/localStorage"; import type { MessageConnect, TMessage } from "@Packages/message/types"; import { obtainBlackList } from "@App/pkg/utils/utils"; import type { CompiledResource, Resource } from "@App/app/repo/resource"; +import { trimScriptInfo } from "@App/app/service/content/utils"; +import { ScriptRuntime } from "@App/app/service/content/script_runtime"; +import type { ScriptExecutor } from "@App/app/service/content/script_executor"; +import { ScriptEnvTag } from "@Packages/message/consts"; +import type { Message } from "@Packages/message/types"; +import type { GMInfoEnv } from "@App/app/service/content/types"; initTestEnv(); @@ -719,6 +725,19 @@ const _createRuntimeContext = () => { return { runtime, mockSystemConfig, mockScriptService, mockScriptDAO, mockGroup }; }; +const _createPageLoadScriptInfo = ( + runtime: RuntimeService, + scriptRes: ScriptRunResource, + cache: Record +) => { + const createPageLoadScriptInfo = ( + runtime as unknown as { + createPageLoadScriptInfo: (scriptRes: ScriptRunResource, cache: Record) => ScriptLoadInfo; + } + ).createPageLoadScriptInfo.bind(runtime); + return createPageLoadScriptInfo(scriptRes, cache); +}; + // ───────────────────────────────────────────────────────────────────────────── describe("shouldSkipPageLoadScript 页面脚本加载过滤规则", () => { @@ -865,13 +884,96 @@ describe("page-load resource cache", () => { localResources: [], }; - const pageInfo = (runtime as any).createPageLoadScriptInfo(scriptRes, cache); + const pageInfo = _createPageLoadScriptInfo(runtime, scriptRes, cache); - expect(pageInfo.resourceByType.require[sharedKey].content).toBe("require content"); - expect(pageInfo.resourceByType["require-css"][sharedKey].content).toBe("css content"); - expect(pageInfo.resourceByType.resource[sharedKey].content).toBe("resource content"); + expect(pageInfo.resourceByType!.require[sharedKey].content).toBe("require content"); + expect(pageInfo.resourceByType!["require-css"][sharedKey].content).toBe("css content"); + expect(pageInfo.resourceByType!.resource[sharedKey].content).toBe("resource content"); expect(pageInfo.resource[sharedKey].content).toBe("resource content"); }); + + it("keeps the service-worker page-load producer within the page bridge DTO", () => { + const { runtime } = _createRuntimeContext(); + const scriptRes = _createScriptRunResource(_createMockScript()); + const cache = { + scriptCacheKey: "cache-key", + code: "console.log(1)", + scriptUrlPatterns: [], + originalUrlPatterns: [], + metadataStr: "", + userConfigStr: "", + userConfig: undefined, + resourceByType: { require: {}, "require-css": {}, resource: {} }, + localResources: [], + }; + + const pageInfo = _createPageLoadScriptInfo(runtime, scriptRes, cache); + const trimmed = trimScriptInfo(pageInfo as ScriptLoadInfo); + + expect(Object.keys(trimmed).sort()).toEqual( + [ + "checktime", + "code", + "createtime", + "flag", + "metadata", + "metadataStr", + "name", + "namespace", + "resource", + "requireCssResource", + "scriptUrlPatterns", + "userConfig", + "userConfigStr", + "uuid", + "value", + ].sort() + ); + }); + + it("accepts the real page-load producer output and rejects an extra bridge field", () => { + const { runtime } = _createRuntimeContext(); + const scriptRes = _createScriptRunResource(_createMockScript()); + const pageInfo = _createPageLoadScriptInfo(runtime, scriptRes, { + scriptCacheKey: "cache-key", + code: "console.log(1)", + scriptUrlPatterns: [], + originalUrlPatterns: [], + metadataStr: "", + userConfigStr: "", + userConfig: undefined, + resourceByType: { require: {}, "require-css": {}, resource: {} }, + localResources: [], + }); + const producedScript = trimScriptInfo(pageInfo as ScriptLoadInfo); + const envInfo = { userAgentData: {}, sandboxMode: "raw", isIncognito: false } as GMInfoEnv; + const handlers = new Map unknown>(); + const server = { + on: (name: string, handler: (data: unknown) => unknown) => handlers.set(name, handler), + } as unknown as Server; + const startScripts = vi.fn(); + const executor = { + checkEarlyStartScript: vi.fn(), + emitEvent: vi.fn(), + startScripts, + valueUpdate: vi.fn(), + } as unknown as ScriptExecutor; + const contentRuntime = new ScriptRuntime(ScriptEnvTag.inject, server, {} as Message, executor, undefined); + contentRuntime.init(); + + const pageLoad = handlers.get("pageLoad"); + expect(pageLoad).toBeDefined(); + pageLoad!({ scripts: [producedScript], envInfo }); + + expect(startScripts).toHaveBeenCalledWith([producedScript], envInfo); + + pageLoad!({ + scripts: [{ ...producedScript, originalUrlPatterns: [] }], + envInfo, + }); + + expect(startScripts).toHaveBeenCalledTimes(1); + }); }); // ───────────────────────────────────────────────────────────────────────────── From 816816a947c1f331f5f6ead97bec35fad1c62230 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:00:20 +0900 Subject: [PATCH 2/8] =?UTF-8?q?=E8=A3=9C=E5=85=85=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 1c64f7edd..3d97cec8f 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -192,6 +192,8 @@ export function compileInjectScriptByFlag( /** * 脚本加载信息。(Inject/Content环境用,避免过多不必要信息公开,减少页面加载信息存储量) + * 旧版 trimScriptInfo() 使用 spread,会把 originalUrlPatterns 传到页面桥,导致脚本不启动。 + * 新版 pickPageLoadScriptFields() 通过 allowlist 丢弃该内部字段,同时防止未来内部字段继续泄漏。 */ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { // --- 处理 resource --- From bf70231a113a22c9c03fe0bbc8a125f97ef61670 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:26:57 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20=E5=8A=A0=E9=80=9F=20r?= =?UTF-8?q?untime=20guard=20push=20=E5=89=8D=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .husky/pre-push | 2 +- package.json | 6 +- scripts/runtime-guard.mjs | 144 +++++++++++++++++++++++++++++++++ scripts/runtime-guard.test.mjs | 39 +++++++++ 4 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 scripts/runtime-guard.mjs create mode 100644 scripts/runtime-guard.test.mjs diff --git a/.husky/pre-push b/.husky/pre-push index f8d45b5cb..5083d2a50 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -6,4 +6,4 @@ if [ "$SKIP_RUNTIME_GUARD" = "1" ]; then fi echo "🔍 Running runtime guard before push..." -exec pnpm run guard:runtime +exec pnpm run guard:runtime:push diff --git a/package.json b/package.json index 6f612c6c5..256a04cf2 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,11 @@ "coverage:ci": "vitest run --coverage --silent --reporter=default --reporter.default.summary=false", "test:e2e:install": "pnpm exec playwright install chromium", "test:e2e": "pnpm exec playwright test", - "test:runtime-contract": "pnpm exec vitest run --no-coverage packages/message/server.test.ts src/app/service/content/page_load_contract.test.ts src/app/service/content/utils.test.ts src/app/service/service_worker/runtime.test.ts", + "test:runtime-contract": "pnpm exec vitest run --no-coverage packages/message/server.test.ts src/app/service/content/page_load_contract.test.ts src/app/service/content/utils.test.ts src/app/service/service_worker/runtime.test.ts scripts/runtime-guard.test.mjs", "test:e2e:runtime": "pnpm exec playwright test e2e/runtime-bootstrap.spec.ts --workers=1 --retries=0", - "guard:runtime": "pnpm run typecheck && pnpm run test:runtime-contract && pnpm run build && pnpm run test:e2e:runtime", + "guard:runtime:static": "pnpm exec concurrently --kill-others-on-fail --success all -g \"pnpm run typecheck\" \"pnpm run test:runtime-contract\"", + "guard:runtime": "pnpm run guard:runtime:static && pnpm run build && pnpm run test:e2e:runtime", + "guard:runtime:push": "node ./scripts/runtime-guard.mjs", "test:e2e:ui": "pnpm exec playwright test --ui", "validate:yaml": "node ./scripts/validate-yaml.mjs", "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all", diff --git a/scripts/runtime-guard.mjs b/scripts/runtime-guard.mjs new file mode 100644 index 000000000..8b05e1b79 --- /dev/null +++ b/scripts/runtime-guard.mjs @@ -0,0 +1,144 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +const ZERO_SHA = /^0{40}$/; +const PNPM = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + +const fullGuardPaths = [ + /^\.github\/workflows\//, + /^\.husky\//, + /^e2e\//, + /^packages\//, + /^rspack-plugins\//, + /^scripts\//, + /^src\//, + /^package\.json$/, + /^pnpm-lock\.yaml$/, + /^pnpm-workspace\.yaml$/, + /^playwright\.config\./, + /^rspack\.config\./, + /^tsconfig(?:\..+)?\.json$/, +]; + +const fastGuardPaths = [/^tests\//, /^vitest\.config\./, /\.test\.[cm]?[jt]sx?$/]; + +export function isDocumentationPath(filePath) { + return ( + filePath === "LICENSE" || + filePath === ".github/pull_request_template.md" || + filePath.startsWith(".github/ISSUE_TEMPLATE/") || + filePath.startsWith("docs/") || + /\.(?:md|mdx|txt)$/.test(filePath) + ); +} + +function matchesAny(filePath, patterns) { + return patterns.some((pattern) => pattern.test(filePath)); +} + +export function classifyChangedPaths(paths) { + const changedPaths = [...new Set(paths)].filter(Boolean); + if (changedPaths.length === 0 || changedPaths.every(isDocumentationPath)) return "skip"; + if ( + changedPaths.some( + (filePath) => + matchesAny(filePath, fullGuardPaths) && (filePath.startsWith("e2e/") || !matchesAny(filePath, fastGuardPaths)) + ) + ) { + return "full"; + } + if (changedPaths.every((filePath) => matchesAny(filePath, fastGuardPaths))) return "fast"; + return "full"; +} + +export function parsePrePushRefs(input) { + const lines = input + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const refs = lines.map((line) => line.split(/\s+/)); + if (refs.some((fields) => fields.length !== 4 || fields.some((field) => !field))) { + throw new Error("invalid pre-push ref input"); + } + return refs.map(([localRef, localSha, remoteRef, remoteSha]) => ({ localRef, localSha, remoteRef, remoteSha })); +} + +function runGit(args) { + const result = spawnSync("git", args, { encoding: "utf8" }); + if (result.error || result.status !== 0) { + throw new Error(result.error?.message ?? ((result.stderr ?? "").trim() || `git ${args.join(" ")} failed`)); + } + return result.stdout; +} + +function pushedPaths(refs) { + const paths = []; + for (const { localSha, remoteSha } of refs) { + if (ZERO_SHA.test(localSha)) continue; + if (ZERO_SHA.test(remoteSha)) return { paths: [], reason: "new branch" }; + paths.push(...runGit(["diff", "--name-only", "--no-renames", remoteSha, localSha]).split(/\r?\n/)); + } + return { paths: [...new Set(paths.filter(Boolean))] }; +} + +function workingTreePaths() { + return [ + ...runGit(["diff", "--name-only"]), + ...runGit(["diff", "--cached", "--name-only"]), + ...runGit(["ls-files", "--others", "--exclude-standard"]), + ] + .flatMap((output) => output.split(/\r?\n/)) + .filter(Boolean); +} + +function runPnpm(args) { + const result = spawnSync(PNPM, args, { stdio: "inherit" }); + if (result.error) { + console.error(result.error.message); + return 1; + } + return result.status ?? 1; +} + +export function main(input = readFileSync(0, "utf8")) { + let refs; + let pushed; + try { + refs = parsePrePushRefs(input); + pushed = refs.length === 0 ? { paths: [], reason: "missing pre-push ref input" } : pushedPaths(refs); + } catch (error) { + console.error(`Runtime guard could not classify pushed paths: ${error.message}`); + console.error("Running the full runtime guard."); + return runPnpm(["run", "guard:runtime"]); + } + + const classification = pushed.reason ? "full" : classifyChangedPaths(pushed.paths); + if (classification === "skip") { + console.log("⏭ Runtime guard skipped: pushed changes are documentation-only or empty."); + return 0; + } + + if (classification !== "skip") { + const dirtyRelevantPaths = [...new Set(workingTreePaths())].filter((filePath) => !isDocumentationPath(filePath)); + if (dirtyRelevantPaths.length > 0) { + console.error("Runtime guard refused: relevant working-tree changes are not included in the pushed commits:"); + dirtyRelevantPaths.forEach((filePath) => console.error(` ${filePath}`)); + return 1; + } + } + + if (classification === "fast") { + console.log("🔍 Runtime guard: running static checks for test-only changes."); + return runPnpm(["run", "guard:runtime:static"]); + } + + console.log("🔍 Runtime guard: running the full build and runtime smoke test."); + return runPnpm(["run", "guard:runtime"]); +} + +const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : null; +if (entrypoint && import.meta.url === entrypoint) { + process.exitCode = main(); +} diff --git a/scripts/runtime-guard.test.mjs b/scripts/runtime-guard.test.mjs new file mode 100644 index 000000000..5922cd1dc --- /dev/null +++ b/scripts/runtime-guard.test.mjs @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { classifyChangedPaths, parsePrePushRefs } from "./runtime-guard.mjs"; + +describe("runtime guard path classification", () => { + it("skips documentation-only changes", () => { + expect(classifyChangedPaths(["README.md", "docs/develop.md", "LICENSE"])).toBe("skip"); + }); + + it("runs static checks for test-only changes", () => { + expect(classifyChangedPaths(["src/app/service/content/utils.test.ts", "tests/helper.test.mjs"])).toBe("fast"); + }); + + it("runs the full guard for runtime, build, CI, and unknown changes", () => { + expect(classifyChangedPaths(["src/app/service/content/utils.ts"])).toBe("full"); + expect(classifyChangedPaths(["rspack-plugins/ZipExecutionPlugin.ts"])).toBe("full"); + expect(classifyChangedPaths([".github/workflows/test.yaml"])).toBe("full"); + expect(classifyChangedPaths(["e2e/runtime-bootstrap.spec.ts"])).toBe("full"); + expect(classifyChangedPaths(["unknown-config.ini"])).toBe("full"); + }); + + it("treats a mixed documentation and executable change as full", () => { + expect(classifyChangedPaths(["docs/develop.md", "package.json"])).toBe("full"); + }); +}); + +describe("pre-push ref parsing", () => { + it("parses all refs from one hook invocation", () => { + expect( + parsePrePushRefs( + "refs/heads/feature 1111111111111111111111111111111111111111 refs/heads/feature 2222222222222222222222222222222222222222\n" + + "refs/tags/v1 3333333333333333333333333333333333333333 refs/tags/v1 4444444444444444444444444444444444444444\n" + ) + ).toHaveLength(2); + }); + + it("rejects malformed ref input so the hook can fail closed", () => { + expect(() => parsePrePushRefs("refs/heads/feature only-three-fields")).toThrow("invalid pre-push ref input"); + }); +}); From c33643c0573ccf61d325014fef64df11e85b3bde Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:48:22 +0900 Subject: [PATCH 4/8] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20=E7=B0=A1=E5=8C=96=20r?= =?UTF-8?q?untime=20guard=20=E8=88=87=20CI=20=E5=89=8D=E7=BD=AE=E6=AA=A2?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yaml | 56 +++++++++++++++++++- package.json | 6 +-- scripts/runtime-guard.mjs | 96 ++++++++++++++++++++++++++++++---- scripts/runtime-guard.test.mjs | 22 +++++++- 4 files changed, 163 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 586cbafe9..65c7ac844 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -169,9 +169,58 @@ jobs: - name: Upload coverage reports to Codecov with GitHub Action uses: codecov/codecov-action@v5 + build: + runs-on: ubuntu-latest + name: Build extension + needs: [lint, test-shards] + if: ${{ needs.lint.result == 'success' && needs.test-shards.result == 'success' }} + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Setup pnpm + env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 + run: | + corepack enable + corepack install + pnpm --version + + - name: Get pnpm store path + id: pnpm-store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v6 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('package.json', 'pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-pnpm- + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Build extension + run: pnpm build + + - name: Upload extension build + uses: actions/upload-artifact@v6 + with: + name: extension-build + path: dist/ext + if-no-files-found: error + retention-days: 1 + e2e: runs-on: ubuntu-latest name: Run E2E tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) + needs: build + if: ${{ needs.build.result == 'success' }} strategy: fail-fast: false matrix: @@ -224,8 +273,11 @@ jobs: if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm test:e2e:install - - name: Build extension - run: pnpm build + - name: Download extension build + uses: actions/download-artifact@v6 + with: + name: extension-build + path: dist/ext - name: Run E2E tests run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} diff --git a/package.json b/package.json index 256a04cf2..48a988619 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,8 @@ "coverage:ci": "vitest run --coverage --silent --reporter=default --reporter.default.summary=false", "test:e2e:install": "pnpm exec playwright install chromium", "test:e2e": "pnpm exec playwright test", - "test:runtime-contract": "pnpm exec vitest run --no-coverage packages/message/server.test.ts src/app/service/content/page_load_contract.test.ts src/app/service/content/utils.test.ts src/app/service/service_worker/runtime.test.ts scripts/runtime-guard.test.mjs", - "test:e2e:runtime": "pnpm exec playwright test e2e/runtime-bootstrap.spec.ts --workers=1 --retries=0", - "guard:runtime:static": "pnpm exec concurrently --kill-others-on-fail --success all -g \"pnpm run typecheck\" \"pnpm run test:runtime-contract\"", - "guard:runtime": "pnpm run guard:runtime:static && pnpm run build && pnpm run test:e2e:runtime", + "test:runtime-contract": "pnpm exec vitest run --no-coverage packages/message/server.test.ts src/app/service/content/page_load_contract.test.ts src/app/service/content/utils.test.ts src/app/service/service_worker/runtime.test.ts", + "guard:runtime": "node ./scripts/runtime-guard.mjs --full", "guard:runtime:push": "node ./scripts/runtime-guard.mjs", "test:e2e:ui": "pnpm exec playwright test --ui", "validate:yaml": "node ./scripts/validate-yaml.mjs", diff --git a/scripts/runtime-guard.mjs b/scripts/runtime-guard.mjs index 8b05e1b79..ed56c07db 100644 --- a/scripts/runtime-guard.mjs +++ b/scripts/runtime-guard.mjs @@ -5,6 +5,48 @@ import { pathToFileURL } from "node:url"; const ZERO_SHA = /^0{40}$/; const PNPM = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +const STATIC_GUARD_ARGS = [ + "exec", + "concurrently", + "--kill-others-on-fail", + "--success", + "all", + "-g", + "pnpm run typecheck", + "pnpm run test:runtime-contract", +]; + +const commonEnvironmentFailurePatterns = [ + /(?:pnpm|spawnSync).*?(?:not found|ENOENT)/i, + /ERR_PNPM_/i, + /\b(?:EAI_AGAIN|ECONNREFUSED|ECONNRESET|ENETUNREACH|ETIMEDOUT|ENOTFOUND)\b/i, + /(?:fetch failed|unable to resolve host|could not resolve host|failed to fetch)/i, + /(?:Executable doesn't exist|Please run.*playwright install)/i, + /(?:Missing X server|no usable sandbox|sandbox.*(?:failed|not available))/i, + /\b(?:EACCES|EMFILE|ENFILE|EPERM)\b/i, + /(?:JavaScript heap out of memory|FATAL ERROR:.*heap|out of memory)/i, +]; + +const e2eEnvironmentFailurePatterns = [ + /\b(?:SIGABRT|SIGTRAP|SIGSEGV)\b/i, + /thermal_state_observer/i, + /browserType\.(?:launch|launchPersistentContext)/i, + /launchPersistentContext/i, +]; + +function isEnvironmentFailure(stage, output) { + const patterns = + stage === "e2e" + ? [...commonEnvironmentFailurePatterns, ...e2eEnvironmentFailurePatterns] + : commonEnvironmentFailurePatterns; + return patterns.some((pattern) => pattern.test(output)); +} + +const definiteFailurePatterns = { + static: [/error TS\d+/i, /Found \d+ error/i, /(?:Test Files|Tests).*failed/i, /AssertionError/i], + build: [/ERROR in/i, /Module not found/i, /SyntaxError/i, /error TS\d+/i, /Rspack compiled with \d+ error/i], + e2e: [/Error: expect\(/i, /TimeoutError/i, /(?:Test Files|Tests).*failed/i, /toHave[A-Z]/i], +}; const fullGuardPaths = [ /^\.github\/workflows\//, @@ -93,13 +135,42 @@ function workingTreePaths() { .filter(Boolean); } +export function shouldBlockGuardFailure(stage, output) { + if (isEnvironmentFailure(stage, output)) return false; + return (definiteFailurePatterns[stage] ?? []).some((pattern) => pattern.test(output)); +} + function runPnpm(args) { - const result = spawnSync(PNPM, args, { stdio: "inherit" }); - if (result.error) { - console.error(result.error.message); - return 1; + const result = spawnSync(PNPM, args, { encoding: "utf8", stdio: ["inherit", "pipe", "pipe"] }); + const errorMessage = result.error?.message ?? ""; + const output = `${result.stdout ?? ""}${result.stderr ?? ""}${errorMessage ? `\n${errorMessage}` : ""}`; + process.stdout.write(result.stdout ?? ""); + process.stderr.write(result.stderr ?? ""); + return { output, status: result.status ?? 1 }; +} + +function runGuardCommand(stage, args, allowEnvironmentFailure) { + const result = runPnpm(args); + if (result.status === 0) return 0; + if (allowEnvironmentFailure && !shouldBlockGuardFailure(stage, result.output)) { + console.error(`⚠ Runtime guard ${stage} could not complete because of the local environment; allowing push.`); + return 0; } - return result.status ?? 1; + return result.status; +} + +function runFullGuard(allowEnvironmentFailure = false) { + const staticStatus = runGuardCommand("static", STATIC_GUARD_ARGS, allowEnvironmentFailure); + if (staticStatus !== 0) return staticStatus; + + const buildStatus = runGuardCommand("build", ["run", "build"], allowEnvironmentFailure); + if (buildStatus !== 0) return buildStatus; + + return runGuardCommand( + "e2e", + ["exec", "playwright", "test", "e2e/runtime-bootstrap.spec.ts", "--workers=1", "--retries=0"], + allowEnvironmentFailure + ); } export function main(input = readFileSync(0, "utf8")) { @@ -111,7 +182,7 @@ export function main(input = readFileSync(0, "utf8")) { } catch (error) { console.error(`Runtime guard could not classify pushed paths: ${error.message}`); console.error("Running the full runtime guard."); - return runPnpm(["run", "guard:runtime"]); + return runFullGuard(true); } const classification = pushed.reason ? "full" : classifyChangedPaths(pushed.paths); @@ -121,7 +192,12 @@ export function main(input = readFileSync(0, "utf8")) { } if (classification !== "skip") { - const dirtyRelevantPaths = [...new Set(workingTreePaths())].filter((filePath) => !isDocumentationPath(filePath)); + let dirtyRelevantPaths = []; + try { + dirtyRelevantPaths = [...new Set(workingTreePaths())].filter((filePath) => !isDocumentationPath(filePath)); + } catch (error) { + console.error(`⚠ Runtime guard could not inspect the working tree; continuing: ${error.message}`); + } if (dirtyRelevantPaths.length > 0) { console.error("Runtime guard refused: relevant working-tree changes are not included in the pushed commits:"); dirtyRelevantPaths.forEach((filePath) => console.error(` ${filePath}`)); @@ -131,14 +207,14 @@ export function main(input = readFileSync(0, "utf8")) { if (classification === "fast") { console.log("🔍 Runtime guard: running static checks for test-only changes."); - return runPnpm(["run", "guard:runtime:static"]); + return runGuardCommand("static", STATIC_GUARD_ARGS, true); } console.log("🔍 Runtime guard: running the full build and runtime smoke test."); - return runPnpm(["run", "guard:runtime"]); + return runFullGuard(true); } const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : null; if (entrypoint && import.meta.url === entrypoint) { - process.exitCode = main(); + process.exitCode = process.argv.includes("--full") ? runFullGuard() : main(); } diff --git a/scripts/runtime-guard.test.mjs b/scripts/runtime-guard.test.mjs index 5922cd1dc..3ac755868 100644 --- a/scripts/runtime-guard.test.mjs +++ b/scripts/runtime-guard.test.mjs @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { classifyChangedPaths, parsePrePushRefs } from "./runtime-guard.mjs"; +import { classifyChangedPaths, parsePrePushRefs, shouldBlockGuardFailure } from "./runtime-guard.mjs"; describe("runtime guard path classification", () => { it("skips documentation-only changes", () => { @@ -37,3 +37,23 @@ describe("pre-push ref parsing", () => { expect(() => parsePrePushRefs("refs/heads/feature only-three-fields")).toThrow("invalid pre-push ref input"); }); }); + +describe("pre-push guard failure policy", () => { + it("blocks obvious code failures", () => { + expect(shouldBlockGuardFailure("static", "src/foo.ts:1:1 - error TS2322: type mismatch")).toBe(true); + expect(shouldBlockGuardFailure("static", "AssertionError: expected value to be true")).toBe(true); + expect(shouldBlockGuardFailure("static", "Test Files 1 failed\nnetwork rule assertion failed")).toBe(true); + expect(shouldBlockGuardFailure("e2e", "Error: expect(locator).toHaveAttribute failed")).toBe(true); + }); + + it("allows local tool and browser environment failures", () => { + expect(shouldBlockGuardFailure("static", "spawnSync pnpm ENOENT")).toBe(false); + expect(shouldBlockGuardFailure("e2e", "thermal_state_observer_mac.mm:140 SIGTRAP")).toBe(false); + expect( + shouldBlockGuardFailure( + "e2e", + "browserType.launchPersistentContext: Target page, context or browser has been closed" + ) + ).toBe(false); + }); +}); From 754bf8df81a81564909a2517b5d7b9a0547fb642 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:07:05 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=A7=B9=20=E6=94=B6=E7=BC=A9=20runtime?= =?UTF-8?q?=20guard=20=E4=B8=8E=20CI=20=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yaml | 58 +++------------------------------- scripts/runtime-guard.mjs | 33 ++----------------- scripts/runtime-guard.test.mjs | 11 ++----- 3 files changed, 10 insertions(+), 92 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 65c7ac844..4809bf07f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -169,58 +169,11 @@ jobs: - name: Upload coverage reports to Codecov with GitHub Action uses: codecov/codecov-action@v5 - build: - runs-on: ubuntu-latest - name: Build extension - needs: [lint, test-shards] - if: ${{ needs.lint.result == 'success' && needs.test-shards.result == 'success' }} - steps: - - uses: actions/checkout@v6 - - name: Use Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Setup pnpm - env: - COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 - run: | - corepack enable - corepack install - pnpm --version - - - name: Get pnpm store path - id: pnpm-store - shell: bash - run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - - - name: Cache pnpm store - uses: actions/cache@v6 - with: - path: ${{ steps.pnpm-store.outputs.path }} - key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('package.json', 'pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-pnpm- - - - name: Install dependencies - run: pnpm i --frozen-lockfile - - - name: Build extension - run: pnpm build - - - name: Upload extension build - uses: actions/upload-artifact@v6 - with: - name: extension-build - path: dist/ext - if-no-files-found: error - retention-days: 1 - e2e: runs-on: ubuntu-latest name: Run E2E tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) - needs: build - if: ${{ needs.build.result == 'success' }} + needs: [lint, test-shards] + if: ${{ needs.lint.result == 'success' && needs.test-shards.result == 'success' }} strategy: fail-fast: false matrix: @@ -273,11 +226,8 @@ jobs: if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm test:e2e:install - - name: Download extension build - uses: actions/download-artifact@v6 - with: - name: extension-build - path: dist/ext + - name: Build extension + run: pnpm build - name: Run E2E tests run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} diff --git a/scripts/runtime-guard.mjs b/scripts/runtime-guard.mjs index ed56c07db..eec7aedc1 100644 --- a/scripts/runtime-guard.mjs +++ b/scripts/runtime-guard.mjs @@ -16,36 +16,10 @@ const STATIC_GUARD_ARGS = [ "pnpm run test:runtime-contract", ]; -const commonEnvironmentFailurePatterns = [ - /(?:pnpm|spawnSync).*?(?:not found|ENOENT)/i, - /ERR_PNPM_/i, - /\b(?:EAI_AGAIN|ECONNREFUSED|ECONNRESET|ENETUNREACH|ETIMEDOUT|ENOTFOUND)\b/i, - /(?:fetch failed|unable to resolve host|could not resolve host|failed to fetch)/i, - /(?:Executable doesn't exist|Please run.*playwright install)/i, - /(?:Missing X server|no usable sandbox|sandbox.*(?:failed|not available))/i, - /\b(?:EACCES|EMFILE|ENFILE|EPERM)\b/i, - /(?:JavaScript heap out of memory|FATAL ERROR:.*heap|out of memory)/i, -]; - -const e2eEnvironmentFailurePatterns = [ - /\b(?:SIGABRT|SIGTRAP|SIGSEGV)\b/i, - /thermal_state_observer/i, - /browserType\.(?:launch|launchPersistentContext)/i, - /launchPersistentContext/i, -]; - -function isEnvironmentFailure(stage, output) { - const patterns = - stage === "e2e" - ? [...commonEnvironmentFailurePatterns, ...e2eEnvironmentFailurePatterns] - : commonEnvironmentFailurePatterns; - return patterns.some((pattern) => pattern.test(output)); -} - const definiteFailurePatterns = { - static: [/error TS\d+/i, /Found \d+ error/i, /(?:Test Files|Tests).*failed/i, /AssertionError/i], + static: [/error TS\d+/i, /Found \d+ error/i, /AssertionError/i], build: [/ERROR in/i, /Module not found/i, /SyntaxError/i, /error TS\d+/i, /Rspack compiled with \d+ error/i], - e2e: [/Error: expect\(/i, /TimeoutError/i, /(?:Test Files|Tests).*failed/i, /toHave[A-Z]/i], + e2e: [/Error: expect\(/i, /toHave[A-Z]/i], }; const fullGuardPaths = [ @@ -136,7 +110,6 @@ function workingTreePaths() { } export function shouldBlockGuardFailure(stage, output) { - if (isEnvironmentFailure(stage, output)) return false; return (definiteFailurePatterns[stage] ?? []).some((pattern) => pattern.test(output)); } @@ -153,7 +126,7 @@ function runGuardCommand(stage, args, allowEnvironmentFailure) { const result = runPnpm(args); if (result.status === 0) return 0; if (allowEnvironmentFailure && !shouldBlockGuardFailure(stage, result.output)) { - console.error(`⚠ Runtime guard ${stage} could not complete because of the local environment; allowing push.`); + console.error(`⚠ Runtime guard ${stage} found no definite code failure; allowing push.`); return 0; } return result.status; diff --git a/scripts/runtime-guard.test.mjs b/scripts/runtime-guard.test.mjs index 3ac755868..c40f878e4 100644 --- a/scripts/runtime-guard.test.mjs +++ b/scripts/runtime-guard.test.mjs @@ -42,18 +42,13 @@ describe("pre-push guard failure policy", () => { it("blocks obvious code failures", () => { expect(shouldBlockGuardFailure("static", "src/foo.ts:1:1 - error TS2322: type mismatch")).toBe(true); expect(shouldBlockGuardFailure("static", "AssertionError: expected value to be true")).toBe(true); - expect(shouldBlockGuardFailure("static", "Test Files 1 failed\nnetwork rule assertion failed")).toBe(true); expect(shouldBlockGuardFailure("e2e", "Error: expect(locator).toHaveAttribute failed")).toBe(true); + expect(shouldBlockGuardFailure("static", "ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL\nerror TS2322")).toBe(true); }); - it("allows local tool and browser environment failures", () => { + it("allows failures without a definite code error", () => { expect(shouldBlockGuardFailure("static", "spawnSync pnpm ENOENT")).toBe(false); expect(shouldBlockGuardFailure("e2e", "thermal_state_observer_mac.mm:140 SIGTRAP")).toBe(false); - expect( - shouldBlockGuardFailure( - "e2e", - "browserType.launchPersistentContext: Target page, context or browser has been closed" - ) - ).toBe(false); + expect(shouldBlockGuardFailure("e2e", "TimeoutError: browser launch timed out")).toBe(false); }); }); From 35441113920710f422053d2c641f9b191d620360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 9 Oct 2026 14:46:37 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E5=8E=BB=E6=8E=89=20p?= =?UTF-8?q?ageLoad=20consumer=20=E7=AB=AF=E7=9A=84=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E6=8B=92=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit producer 已改为 allowlist 投影,且与 consumer 同一构建产出;consumer 端的字段集合检查 不打日志直接丢弃整页脚本,反而新增一条 fail-silent 路径。测试改为只断言 producer 的输出字段集合。 --- .husky/pre-push | 9 - scripts/runtime-guard.mjs | 193 ------------------ scripts/runtime-guard.test.mjs | 54 ----- .../content/page_load_contract.test.ts | 59 ------ src/app/service/content/page_load_contract.ts | 24 +-- src/app/service/content/script_runtime.ts | 15 +- src/app/service/content/utils.ts | 2 - .../service/service_worker/runtime.test.ts | 51 +---- 8 files changed, 4 insertions(+), 403 deletions(-) delete mode 100755 .husky/pre-push delete mode 100644 scripts/runtime-guard.mjs delete mode 100644 scripts/runtime-guard.test.mjs delete mode 100644 src/app/service/content/page_load_contract.test.ts diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100755 index 5083d2a50..000000000 --- a/.husky/pre-push +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh - -if [ "$SKIP_RUNTIME_GUARD" = "1" ]; then - echo "⏭ SKIP_RUNTIME_GUARD=1, skipping runtime guard" - exit 0 -fi - -echo "🔍 Running runtime guard before push..." -exec pnpm run guard:runtime:push diff --git a/scripts/runtime-guard.mjs b/scripts/runtime-guard.mjs deleted file mode 100644 index eec7aedc1..000000000 --- a/scripts/runtime-guard.mjs +++ /dev/null @@ -1,193 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import process from "node:process"; -import { pathToFileURL } from "node:url"; - -const ZERO_SHA = /^0{40}$/; -const PNPM = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; -const STATIC_GUARD_ARGS = [ - "exec", - "concurrently", - "--kill-others-on-fail", - "--success", - "all", - "-g", - "pnpm run typecheck", - "pnpm run test:runtime-contract", -]; - -const definiteFailurePatterns = { - static: [/error TS\d+/i, /Found \d+ error/i, /AssertionError/i], - build: [/ERROR in/i, /Module not found/i, /SyntaxError/i, /error TS\d+/i, /Rspack compiled with \d+ error/i], - e2e: [/Error: expect\(/i, /toHave[A-Z]/i], -}; - -const fullGuardPaths = [ - /^\.github\/workflows\//, - /^\.husky\//, - /^e2e\//, - /^packages\//, - /^rspack-plugins\//, - /^scripts\//, - /^src\//, - /^package\.json$/, - /^pnpm-lock\.yaml$/, - /^pnpm-workspace\.yaml$/, - /^playwright\.config\./, - /^rspack\.config\./, - /^tsconfig(?:\..+)?\.json$/, -]; - -const fastGuardPaths = [/^tests\//, /^vitest\.config\./, /\.test\.[cm]?[jt]sx?$/]; - -export function isDocumentationPath(filePath) { - return ( - filePath === "LICENSE" || - filePath === ".github/pull_request_template.md" || - filePath.startsWith(".github/ISSUE_TEMPLATE/") || - filePath.startsWith("docs/") || - /\.(?:md|mdx|txt)$/.test(filePath) - ); -} - -function matchesAny(filePath, patterns) { - return patterns.some((pattern) => pattern.test(filePath)); -} - -export function classifyChangedPaths(paths) { - const changedPaths = [...new Set(paths)].filter(Boolean); - if (changedPaths.length === 0 || changedPaths.every(isDocumentationPath)) return "skip"; - if ( - changedPaths.some( - (filePath) => - matchesAny(filePath, fullGuardPaths) && (filePath.startsWith("e2e/") || !matchesAny(filePath, fastGuardPaths)) - ) - ) { - return "full"; - } - if (changedPaths.every((filePath) => matchesAny(filePath, fastGuardPaths))) return "fast"; - return "full"; -} - -export function parsePrePushRefs(input) { - const lines = input - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - const refs = lines.map((line) => line.split(/\s+/)); - if (refs.some((fields) => fields.length !== 4 || fields.some((field) => !field))) { - throw new Error("invalid pre-push ref input"); - } - return refs.map(([localRef, localSha, remoteRef, remoteSha]) => ({ localRef, localSha, remoteRef, remoteSha })); -} - -function runGit(args) { - const result = spawnSync("git", args, { encoding: "utf8" }); - if (result.error || result.status !== 0) { - throw new Error(result.error?.message ?? ((result.stderr ?? "").trim() || `git ${args.join(" ")} failed`)); - } - return result.stdout; -} - -function pushedPaths(refs) { - const paths = []; - for (const { localSha, remoteSha } of refs) { - if (ZERO_SHA.test(localSha)) continue; - if (ZERO_SHA.test(remoteSha)) return { paths: [], reason: "new branch" }; - paths.push(...runGit(["diff", "--name-only", "--no-renames", remoteSha, localSha]).split(/\r?\n/)); - } - return { paths: [...new Set(paths.filter(Boolean))] }; -} - -function workingTreePaths() { - return [ - ...runGit(["diff", "--name-only"]), - ...runGit(["diff", "--cached", "--name-only"]), - ...runGit(["ls-files", "--others", "--exclude-standard"]), - ] - .flatMap((output) => output.split(/\r?\n/)) - .filter(Boolean); -} - -export function shouldBlockGuardFailure(stage, output) { - return (definiteFailurePatterns[stage] ?? []).some((pattern) => pattern.test(output)); -} - -function runPnpm(args) { - const result = spawnSync(PNPM, args, { encoding: "utf8", stdio: ["inherit", "pipe", "pipe"] }); - const errorMessage = result.error?.message ?? ""; - const output = `${result.stdout ?? ""}${result.stderr ?? ""}${errorMessage ? `\n${errorMessage}` : ""}`; - process.stdout.write(result.stdout ?? ""); - process.stderr.write(result.stderr ?? ""); - return { output, status: result.status ?? 1 }; -} - -function runGuardCommand(stage, args, allowEnvironmentFailure) { - const result = runPnpm(args); - if (result.status === 0) return 0; - if (allowEnvironmentFailure && !shouldBlockGuardFailure(stage, result.output)) { - console.error(`⚠ Runtime guard ${stage} found no definite code failure; allowing push.`); - return 0; - } - return result.status; -} - -function runFullGuard(allowEnvironmentFailure = false) { - const staticStatus = runGuardCommand("static", STATIC_GUARD_ARGS, allowEnvironmentFailure); - if (staticStatus !== 0) return staticStatus; - - const buildStatus = runGuardCommand("build", ["run", "build"], allowEnvironmentFailure); - if (buildStatus !== 0) return buildStatus; - - return runGuardCommand( - "e2e", - ["exec", "playwright", "test", "e2e/runtime-bootstrap.spec.ts", "--workers=1", "--retries=0"], - allowEnvironmentFailure - ); -} - -export function main(input = readFileSync(0, "utf8")) { - let refs; - let pushed; - try { - refs = parsePrePushRefs(input); - pushed = refs.length === 0 ? { paths: [], reason: "missing pre-push ref input" } : pushedPaths(refs); - } catch (error) { - console.error(`Runtime guard could not classify pushed paths: ${error.message}`); - console.error("Running the full runtime guard."); - return runFullGuard(true); - } - - const classification = pushed.reason ? "full" : classifyChangedPaths(pushed.paths); - if (classification === "skip") { - console.log("⏭ Runtime guard skipped: pushed changes are documentation-only or empty."); - return 0; - } - - if (classification !== "skip") { - let dirtyRelevantPaths = []; - try { - dirtyRelevantPaths = [...new Set(workingTreePaths())].filter((filePath) => !isDocumentationPath(filePath)); - } catch (error) { - console.error(`⚠ Runtime guard could not inspect the working tree; continuing: ${error.message}`); - } - if (dirtyRelevantPaths.length > 0) { - console.error("Runtime guard refused: relevant working-tree changes are not included in the pushed commits:"); - dirtyRelevantPaths.forEach((filePath) => console.error(` ${filePath}`)); - return 1; - } - } - - if (classification === "fast") { - console.log("🔍 Runtime guard: running static checks for test-only changes."); - return runGuardCommand("static", STATIC_GUARD_ARGS, true); - } - - console.log("🔍 Runtime guard: running the full build and runtime smoke test."); - return runFullGuard(true); -} - -const entrypoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : null; -if (entrypoint && import.meta.url === entrypoint) { - process.exitCode = process.argv.includes("--full") ? runFullGuard() : main(); -} diff --git a/scripts/runtime-guard.test.mjs b/scripts/runtime-guard.test.mjs deleted file mode 100644 index c40f878e4..000000000 --- a/scripts/runtime-guard.test.mjs +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { classifyChangedPaths, parsePrePushRefs, shouldBlockGuardFailure } from "./runtime-guard.mjs"; - -describe("runtime guard path classification", () => { - it("skips documentation-only changes", () => { - expect(classifyChangedPaths(["README.md", "docs/develop.md", "LICENSE"])).toBe("skip"); - }); - - it("runs static checks for test-only changes", () => { - expect(classifyChangedPaths(["src/app/service/content/utils.test.ts", "tests/helper.test.mjs"])).toBe("fast"); - }); - - it("runs the full guard for runtime, build, CI, and unknown changes", () => { - expect(classifyChangedPaths(["src/app/service/content/utils.ts"])).toBe("full"); - expect(classifyChangedPaths(["rspack-plugins/ZipExecutionPlugin.ts"])).toBe("full"); - expect(classifyChangedPaths([".github/workflows/test.yaml"])).toBe("full"); - expect(classifyChangedPaths(["e2e/runtime-bootstrap.spec.ts"])).toBe("full"); - expect(classifyChangedPaths(["unknown-config.ini"])).toBe("full"); - }); - - it("treats a mixed documentation and executable change as full", () => { - expect(classifyChangedPaths(["docs/develop.md", "package.json"])).toBe("full"); - }); -}); - -describe("pre-push ref parsing", () => { - it("parses all refs from one hook invocation", () => { - expect( - parsePrePushRefs( - "refs/heads/feature 1111111111111111111111111111111111111111 refs/heads/feature 2222222222222222222222222222222222222222\n" + - "refs/tags/v1 3333333333333333333333333333333333333333 refs/tags/v1 4444444444444444444444444444444444444444\n" - ) - ).toHaveLength(2); - }); - - it("rejects malformed ref input so the hook can fail closed", () => { - expect(() => parsePrePushRefs("refs/heads/feature only-three-fields")).toThrow("invalid pre-push ref input"); - }); -}); - -describe("pre-push guard failure policy", () => { - it("blocks obvious code failures", () => { - expect(shouldBlockGuardFailure("static", "src/foo.ts:1:1 - error TS2322: type mismatch")).toBe(true); - expect(shouldBlockGuardFailure("static", "AssertionError: expected value to be true")).toBe(true); - expect(shouldBlockGuardFailure("e2e", "Error: expect(locator).toHaveAttribute failed")).toBe(true); - expect(shouldBlockGuardFailure("static", "ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL\nerror TS2322")).toBe(true); - }); - - it("allows failures without a definite code error", () => { - expect(shouldBlockGuardFailure("static", "spawnSync pnpm ENOENT")).toBe(false); - expect(shouldBlockGuardFailure("e2e", "thermal_state_observer_mac.mm:140 SIGTRAP")).toBe(false); - expect(shouldBlockGuardFailure("e2e", "TimeoutError: browser launch timed out")).toBe(false); - }); -}); diff --git a/src/app/service/content/page_load_contract.test.ts b/src/app/service/content/page_load_contract.test.ts deleted file mode 100644 index bcf356640..000000000 --- a/src/app/service/content/page_load_contract.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; -import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; -import { - PAGE_LOAD_SCRIPT_REQUIRED_KEYS, - hasValidPageLoadScriptShape, - pickPageLoadScriptFields, -} from "./page_load_contract"; - -const createScriptLoadInfo = (): ScriptLoadInfo => ({ - uuid: "script", - name: "Script", - namespace: "runtime-guard", - metadata: {}, - type: SCRIPT_TYPE_NORMAL, - status: SCRIPT_STATUS_ENABLE, - sort: 0, - runStatus: "complete", - createtime: 1, - checktime: 1, - code: "console.log('source')", - value: {}, - flag: "script-flag", - resource: {}, - originalMetadata: {}, - metadataStr: "", - userConfigStr: "", -}); - -describe("page-load contract", () => { - it("allows optional wire fields to be absent", () => { - const requiredOnly = Object.fromEntries(PAGE_LOAD_SCRIPT_REQUIRED_KEYS.map((key) => [key, key])); - - expect(hasValidPageLoadScriptShape(requiredOnly)).toBe(true); - }); - - it("projects only contract fields without claiming to validate unknown values", () => { - const source = createScriptLoadInfo() as ScriptLoadInfo & { originalUrlPatterns?: unknown[] }; - source.originalUrlPatterns = []; - const resource = {} as TScriptInfo["resource"]; - const requireCssResource = {} as NonNullable; - - const picked = pickPageLoadScriptFields(source, { - resource, - requireCssResource, - code: "", - }); - - expect(Object.hasOwn(picked, "originalUrlPatterns")).toBe(false); - expect(hasValidPageLoadScriptShape(picked)).toBe(true); - }); - - it("rejects unexpected top-level fields", () => { - const value: Record = Object.fromEntries(PAGE_LOAD_SCRIPT_REQUIRED_KEYS.map((key) => [key, key])); - value.unexpected = true; - - expect(hasValidPageLoadScriptShape(value)).toBe(false); - }); -}); diff --git a/src/app/service/content/page_load_contract.ts b/src/app/service/content/page_load_contract.ts index 6053fc680..fdb53dd5b 100644 --- a/src/app/service/content/page_load_contract.ts +++ b/src/app/service/content/page_load_contract.ts @@ -1,6 +1,6 @@ import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; -export const PAGE_LOAD_SCRIPT_REQUIRED_KEYS = [ +const PAGE_LOAD_SCRIPT_REQUIRED_KEYS = [ "uuid", "name", "namespace", @@ -15,7 +15,7 @@ export const PAGE_LOAD_SCRIPT_REQUIRED_KEYS = [ "userConfigStr", ] as const; -export const PAGE_LOAD_SCRIPT_OPTIONAL_KEYS = [ +const PAGE_LOAD_SCRIPT_OPTIONAL_KEYS = [ "author", "checkUpdate", "checkUpdateUrl", @@ -33,28 +33,8 @@ type PageLoadScriptOptionalKey = (typeof PAGE_LOAD_SCRIPT_OPTIONAL_KEYS)[number] export type PageLoadScriptInfo = Pick & Partial>; -const PAGE_LOAD_SCRIPT_ALLOWED_KEYS = new Set([ - ...PAGE_LOAD_SCRIPT_REQUIRED_KEYS, - ...PAGE_LOAD_SCRIPT_OPTIONAL_KEYS, -]); - type PageLoadScriptFieldOverrides = Pick; -/** - * Verifies only the pageLoad DTO top-level key contract. - * - * This is intentionally a boolean shape check rather than a TypeScript type guard: it prevents producer/consumer - * field drift, but it does not claim that unknown cross-context values have been fully validated as TScriptInfo. - */ -export const hasValidPageLoadScriptShape = (value: unknown): boolean => { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - const record = value as Record; - return ( - PAGE_LOAD_SCRIPT_REQUIRED_KEYS.every((key) => Object.hasOwn(record, key)) && - Object.keys(record).every((key) => PAGE_LOAD_SCRIPT_ALLOWED_KEYS.has(key)) - ); -}; - /** * Projects the trusted service-worker ScriptLoadInfo onto the pageLoad wire contract. * Internal producer fields are opt-in: adding a field to ScriptLoadInfo cannot expose it to the page bridge by spread. diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index f2421abba..817dd2a23 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -8,18 +8,6 @@ import type { ScriptEnvTag } from "@Packages/message/consts"; import { onInjectPageLoaded } from "./external"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import { type TExtensionEnv } from "../extension/extension_env"; -import { hasValidPageLoadScriptShape } from "./page_load_contract"; - -const hasValidPageLoadDataShape = (value: unknown): boolean => { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - const record = value as Record; - return ( - Array.isArray(record.scripts) && - record.scripts.every(hasValidPageLoadScriptShape) && - record.envInfo !== null && - typeof record.envInfo === "object" - ); -}; export class ScriptRuntime { constructor( @@ -76,8 +64,7 @@ export class ScriptRuntime { }); this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { - // Keep the existing transport value contract, while rejecting producer/consumer field drift before execution. - if (!hasValidPageLoadDataShape(data)) return; + // 监听事件 this.startScripts(data.scripts, data.envInfo); }); diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 3d97cec8f..1c64f7edd 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -192,8 +192,6 @@ export function compileInjectScriptByFlag( /** * 脚本加载信息。(Inject/Content环境用,避免过多不必要信息公开,减少页面加载信息存储量) - * 旧版 trimScriptInfo() 使用 spread,会把 originalUrlPatterns 传到页面桥,导致脚本不启动。 - * 新版 pickPageLoadScriptFields() 通过 allowlist 丢弃该内部字段,同时防止未来内部字段继续泄漏。 */ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { // --- 处理 resource --- diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 1887ca87b..c316ab921 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -11,7 +11,7 @@ import { } from "@App/app/repo/scripts"; import { buildScriptRunResourceBasic, getCombinedMeta, scriptURLPatternResults } from "./utils"; import type { SystemConfig } from "@App/pkg/config/config"; -import { SenderRuntime, type Group, type Server } from "@Packages/message/server"; +import { SenderRuntime, type Group } from "@Packages/message/server"; import type { ServiceWorkerMessageSend, WindowMessageBody } from "@Packages/message/window_message"; import type { IMessageQueue } from "@Packages/message/message_queue"; import type { ValueService } from "./value"; @@ -23,11 +23,6 @@ import type { MessageConnect, TMessage } from "@Packages/message/types"; import { obtainBlackList } from "@App/pkg/utils/utils"; import type { CompiledResource, Resource } from "@App/app/repo/resource"; import { trimScriptInfo } from "@App/app/service/content/utils"; -import { ScriptRuntime } from "@App/app/service/content/script_runtime"; -import type { ScriptExecutor } from "@App/app/service/content/script_executor"; -import { ScriptEnvTag } from "@Packages/message/consts"; -import type { Message } from "@Packages/message/types"; -import type { GMInfoEnv } from "@App/app/service/content/types"; initTestEnv(); @@ -930,50 +925,6 @@ describe("page-load resource cache", () => { ].sort() ); }); - - it("accepts the real page-load producer output and rejects an extra bridge field", () => { - const { runtime } = _createRuntimeContext(); - const scriptRes = _createScriptRunResource(_createMockScript()); - const pageInfo = _createPageLoadScriptInfo(runtime, scriptRes, { - scriptCacheKey: "cache-key", - code: "console.log(1)", - scriptUrlPatterns: [], - originalUrlPatterns: [], - metadataStr: "", - userConfigStr: "", - userConfig: undefined, - resourceByType: { require: {}, "require-css": {}, resource: {} }, - localResources: [], - }); - const producedScript = trimScriptInfo(pageInfo as ScriptLoadInfo); - const envInfo = { userAgentData: {}, sandboxMode: "raw", isIncognito: false } as GMInfoEnv; - const handlers = new Map unknown>(); - const server = { - on: (name: string, handler: (data: unknown) => unknown) => handlers.set(name, handler), - } as unknown as Server; - const startScripts = vi.fn(); - const executor = { - checkEarlyStartScript: vi.fn(), - emitEvent: vi.fn(), - startScripts, - valueUpdate: vi.fn(), - } as unknown as ScriptExecutor; - const contentRuntime = new ScriptRuntime(ScriptEnvTag.inject, server, {} as Message, executor, undefined); - contentRuntime.init(); - - const pageLoad = handlers.get("pageLoad"); - expect(pageLoad).toBeDefined(); - pageLoad!({ scripts: [producedScript], envInfo }); - - expect(startScripts).toHaveBeenCalledWith([producedScript], envInfo); - - pageLoad!({ - scripts: [{ ...producedScript, originalUrlPatterns: [] }], - envInfo, - }); - - expect(startScripts).toHaveBeenCalledTimes(1); - }); }); // ───────────────────────────────────────────────────────────────────────────── From 15226fbad2af70e0cfaf5c5b52db052b4166e026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 9 Oct 2026 14:46:41 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E5=88=A0=E9=99=A4=20S?= =?UTF-8?q?erver.replace()=20/=20Group.replace()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产代码没有调用方,保留 on() 重复注册直接抛错。 --- packages/message/server.test.ts | 47 +++++++------------------------ packages/message/server.ts | 50 +++++++++++++++------------------ 2 files changed, 32 insertions(+), 65 deletions(-) diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index fae816f0a..27061cf3e 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -36,26 +36,17 @@ afterEach(() => { describe("Server", () => { describe("基本功能测试 1", () => { - it("应该要求显式替换已经注册的消息处理器", async () => { + it("重复注册同一消息处理器时应该抛错并保留原处理器", async () => { const first = vi.fn().mockReturnValue("first"); const second = vi.fn().mockReturnValue("second"); server.on("handler", first); expect(() => server.on("handler", second)).toThrow("duplicate message handler: api/handler"); - server.replace("handler", second); - const response = await client.sendMessage({ action: "api/handler", data: {} }); - expect(response.data).toBe("second"); - expect(first).not.toHaveBeenCalled(); - expect(second).toHaveBeenCalledOnce(); - }); - - it("应该拒绝替换未注册的消息处理器", () => { - expect(() => server.replace("missing", vi.fn())).toThrow( - "cannot replace unregistered message handler: api/missing" - ); + expect(response.data).toBe("first"); + expect(second).not.toHaveBeenCalled(); }); it.concurrent("应该能够注册和调用 API", async () => { @@ -304,32 +295,14 @@ describe("Server", () => { expect(handler).toHaveBeenCalledTimes(1); }); - it("Group 替换处理器时应该保留中间件并要求显式 replace", async () => { - const executionOrder: string[] = []; - const middleware = vi.fn(async (_params: any, _con: any, next: any) => { - executionOrder.push("middleware"); - return await next(); - }); - const group = server.group("api", middleware); - const first = vi.fn(() => { - executionOrder.push("first"); - return "first"; - }); - const second = vi.fn(() => { - executionOrder.push("second"); - return "second"; - }); - - group.on("replace-test", first); - expect(() => group.on("replace-test", second)).toThrow("duplicate message handler: api/api/replace-test"); - - group.replace("replace-test", second); - const response = await client.sendMessage({ action: "api/api/replace-test", data: {} }); + it("Group 重复注册同一消息处理器时应该抛错", () => { + const group = server.group( + "api", + vi.fn(async (_params: any, _con: any, next: any) => await next()) + ); + group.on("duplicate-test", vi.fn()); - expect(response.data).toBe("second"); - expect(executionOrder).toEqual(["middleware", "second"]); - expect(first).not.toHaveBeenCalled(); - expect(second).toHaveBeenCalledOnce(); + expect(() => group.on("duplicate-test", vi.fn())).toThrow("duplicate message handler: api/api/duplicate-test"); }); it("子 Group 应该继承父 Group 的中间件", async () => { diff --git a/packages/message/server.ts b/packages/message/server.ts index 94a2a958f..ee53e4fd8 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -173,13 +173,6 @@ export class Server { this.apiFunctionMap.set(name, func); } - replace(name: string, func: ApiFunction) { - if (!this.apiFunctionMap.has(name)) { - throw new Error(`cannot replace unregistered message handler: ${this.prefix}/${name}`); - } - this.apiFunctionMap.set(name, func); - } - private connectHandle(msg: string, params: any, con: MessageConnect) { const func = this.apiFunctionMap.get(msg); if (func) { @@ -270,29 +263,30 @@ export class Group { return newGroup; } - private wrap(func: ApiFunction): ApiFunction { - if (this.middlewares.length === 0) return func; - return async (params: any, con: IGetSender) => { - let index = 0; - - const next = async (): Promise => { - if (index < this.middlewares.length) { - const middleware = this.middlewares[index++]; - return await middleware(params, con, next); - } - return await func(params, con); - }; - - return await next(); - }; - } - on(name: string, func: ApiFunction) { - this.server.on(`${this.name}${name}`, this.wrap(func)); - } + const fullName = `${this.name}${name}`; + + if (this.middlewares.length === 0) { + // 没有中间件,直接注册 + this.server.on(fullName, func); + } else { + // 有中间件,需要包装处理函数 + this.server.on(fullName, async (params: any, con: IGetSender) => { + let index = 0; + + const next = async (): Promise => { + if (index < this.middlewares.length) { + const middleware = this.middlewares[index++]; + return await middleware(params, con, next); + } else { + // 所有中间件都执行完毕,执行最终的处理函数 + return await func(params, con); + } + }; - replace(name: string, func: ApiFunction) { - this.server.replace(`${this.name}${name}`, this.wrap(func)); + return await next(); + }); + } } } From a4e706d10c70b66f58b008657aa17e69a84d4796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 9 Oct 2026 14:46:45 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=91=B7=20=E5=88=A0=E9=99=A4=20pre-pus?= =?UTF-8?q?h=20hook=EF=BC=8Cruntime=20smoke=20=E6=94=B9=E7=94=B1=20CI=20?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pre-push 每次碰 src/ 都要 build + 起 Chromium,且会因工作区有未提交改动拒绝推送、 用正则判断是否放行,对所有贡献者成本过高。改为: - CI 新增 runtime-smoke job,与 lint / 单测并行;失败时不再启动 4 个 E2E 分片 - guard:runtime 保留为手动命令(package.json 一行串起来),去掉 push 分类脚本 --- .github/workflows/test.yaml | 68 +++++++++++++++++++++++++++++++++++-- docs/develop.md | 1 + package.json | 6 ++-- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4809bf07f..c15969405 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -169,11 +169,75 @@ jobs: - name: Upload coverage reports to Codecov with GitHub Action uses: codecov/codecov-action@v5 + # 运行时启动链路一旦断掉,所有 E2E 都会等到超时才失败;先用单个用例在几秒内暴露,失败时不再启动 E2E 分片。 + runtime-smoke: + runs-on: ubuntu-latest + name: Runtime smoke + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + + - name: Setup pnpm + env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 + run: | + corepack enable + corepack install + pnpm --version + + - name: Get pnpm store path + id: pnpm-store + shell: bash + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache pnpm store + uses: actions/cache@v6 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('package.json', 'pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-pnpm- + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + playwright- + + - name: Install Playwright Chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm test:e2e:install + + - name: Build extension + run: pnpm build + + - name: Run runtime smoke + run: pnpm run test:runtime-smoke + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v6 + with: + name: runtime-smoke-results + path: | + test-results/ + playwright-report/ + retention-days: 14 + e2e: runs-on: ubuntu-latest name: Run E2E tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) - needs: [lint, test-shards] - if: ${{ needs.lint.result == 'success' && needs.test-shards.result == 'success' }} + needs: [lint, test-shards, runtime-smoke] + if: ${{ needs.lint.result == 'success' && needs.test-shards.result == 'success' && needs.runtime-smoke.result == 'success' }} strategy: fail-fast: false matrix: diff --git a/docs/develop.md b/docs/develop.md index a3dfaccf1..f477af170 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -22,6 +22,7 @@ pnpm run typecheck # tsc --noEmit pnpm run test:e2e:install # install Playwright Chromium (first run only) pnpm run test:e2e # Playwright (e2e/*.spec.ts; worker count comes from playwright.config.ts) +pnpm run guard:runtime # typecheck + runtime contract tests, then build + single Chromium runtime smoke pnpm run lint # prettier --check + tsc --noEmit + check:i18n + check:issue-templates, then eslint pnpm run lint-fix # prettier --write + tsc --noEmit + eslint --fix diff --git a/package.json b/package.json index 48a988619..6b96af2a0 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,9 @@ "coverage:ci": "vitest run --coverage --silent --reporter=default --reporter.default.summary=false", "test:e2e:install": "pnpm exec playwright install chromium", "test:e2e": "pnpm exec playwright test", - "test:runtime-contract": "pnpm exec vitest run --no-coverage packages/message/server.test.ts src/app/service/content/page_load_contract.test.ts src/app/service/content/utils.test.ts src/app/service/service_worker/runtime.test.ts", - "guard:runtime": "node ./scripts/runtime-guard.mjs --full", - "guard:runtime:push": "node ./scripts/runtime-guard.mjs", + "test:runtime-contract": "pnpm exec vitest run --no-coverage packages/message/server.test.ts src/app/service/content/utils.test.ts src/app/service/service_worker/runtime.test.ts", + "test:runtime-smoke": "pnpm exec playwright test e2e/runtime-bootstrap.spec.ts --workers=1 --retries=0", + "guard:runtime": "concurrently -g --kill-others-on-fail \"pnpm run typecheck\" \"pnpm run test:runtime-contract\" && pnpm run build && pnpm run test:runtime-smoke", "test:e2e:ui": "pnpm exec playwright test --ui", "validate:yaml": "node ./scripts/validate-yaml.mjs", "validate:yaml:all": "node ./scripts/validate-yaml.mjs --all",