diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 586cbafe9..c15969405 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -169,9 +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, 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/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..6b96af2a0 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/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", diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index b7b1ff007..27061cf3e 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -36,6 +36,19 @@ 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"); + + const response = await client.sendMessage({ action: "api/handler", data: {} }); + + expect(response.data).toBe("first"); + expect(second).not.toHaveBeenCalled(); + }); + it.concurrent("应该能够注册和调用 API", async () => { const mockHandler = vi.fn().mockResolvedValue("test response"); @@ -282,6 +295,16 @@ describe("Server", () => { expect(handler).toHaveBeenCalledTimes(1); }); + 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(() => group.on("duplicate-test", vi.fn())).toThrow("duplicate message handler: api/api/duplicate-test"); + }); + it("子 Group 应该继承父 Group 的中间件", async () => { const middlewareOrder: string[] = []; diff --git a/packages/message/server.ts b/packages/message/server.ts index 3df109f80..ee53e4fd8 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,9 @@ 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); } 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..fdb53dd5b --- /dev/null +++ b/src/app/service/content/page_load_contract.ts @@ -0,0 +1,55 @@ +import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; + +const PAGE_LOAD_SCRIPT_REQUIRED_KEYS = [ + "uuid", + "name", + "namespace", + "metadata", + "createtime", + "checktime", + "code", + "value", + "flag", + "resource", + "metadataStr", + "userConfigStr", +] as const; + +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>; + +type PageLoadScriptFieldOverrides = Pick; + +/** + * 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/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..c316ab921 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, @@ -22,6 +22,7 @@ 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"; initTestEnv(); @@ -719,6 +720,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 +879,52 @@ 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() + ); + }); }); // ─────────────────────────────────────────────────────────────────────────────