From 5319f4e9fc453b1bdd322b9b3478b6a027f53433 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:47:14 +0900 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20=E7=BB=9F=E4=B8=80=20example/te?= =?UTF-8?q?sts=20=E6=B5=8B=E8=AF=95=E7=BB=93=E6=9E=9C=E4=B8=8E=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E9=AA=8C=E8=AF=81=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/references/verification-methods.md | 93 +- docs/verification.md | 114 +- e2e/gm-api.spec.ts | 216 +++- example/tests/early_inject_content_test.js | 298 +++-- example/tests/early_inject_page_test.js | 362 ++++-- example/tests/gm_api_async_test.js | 687 +++++++---- example/tests/gm_api_sync_test.js | 995 +++++++++------ example/tests/gm_download_test.js | 919 +++++++++----- example/tests/gm_menu_test.js | 272 ++-- example/tests/gm_value_test.js | 469 +++---- example/tests/gm_xhr_cookie_test.js | 346 +++--- example/tests/gm_xhr_redirect_test.js | 88 +- example/tests/gm_xhr_test.js | 79 +- example/tests/inject_content_test.js | 205 ++- example/tests/lib/README.md | 125 +- example/tests/lib/sctest.js | 782 +++++++++--- example/tests/lib/sctest.test.js | 211 +++- example/tests/sandbox_test.js | 1305 +++++++++++--------- example/tests/unwrap_e2e_test.js | 41 +- example/tests/unwrap_test.js | 15 +- example/tests/window_message_test.js | 189 +-- 21 files changed, 5026 insertions(+), 2785 deletions(-) diff --git a/docs/references/verification-methods.md b/docs/references/verification-methods.md index 25c7b3c07..464974dc4 100644 --- a/docs/references/verification-methods.md +++ b/docs/references/verification-methods.md @@ -1,12 +1,17 @@ # Verification methods -[`../verification.md`](../verification.md) chooses the form; this file holds the patterns that reach behaviour the UI does not expose directly. Each is written twice where the two forms differ: driving a session ([`../../e2e/README.md`](../../e2e/README.md#8-verification-sessions)) and authoring a spec. Failures and gotchas are [`verification-debugging.md`](verification-debugging.md)'s. +[`../verification.md`](../verification.md) chooses the form; this file holds the patterns that reach behaviour the UI +does not expose directly. Each is written twice where the two forms differ: driving a session +([`../../e2e/README.md`](../../e2e/README.md#8-verification-sessions)) and authoring a spec. Failures and gotchas are +[`verification-debugging.md`](verification-debugging.md)'s. ## Script execution: GM APIs and injection -Making a userscript actually inject and run needs two things: the `userScripts` permission granted, and the permission prompt answered. +Making a userscript actually inject and run needs two things: the `userScripts` permission granted, and the permission +prompt answered. -A session grants `userScripts` at `start`, so injection works out of the box. It does **not** auto-approve prompts — a GM API that needs a grant opens `confirm.html`, which you answer like any other page: +A session grants `userScripts` at `start`, so injection works out of the box. It does **not** auto-approve prompts — a +GM API that needs a grant opens `confirm.html`, which you answer like any other page: ```bash node e2e/drive.mjs pages # 找到 confirm.html @@ -15,55 +20,76 @@ node e2e/drive.mjs click "[data-testid=confirm-duration-permanent]" node e2e/drive.mjs click "[data-testid=confirm-allow]" ``` -In a spec, `testWithUserScripts` and `autoApprovePermissions` solve both ([`../../e2e/README.md`](../../e2e/README.md#3-harness-chain)) — import them rather than re-deriving the launch dance. +In a spec, `testWithUserScripts` and `autoApprovePermissions` solve both +([`../../e2e/README.md`](../../e2e/README.md#3-harness-chain)) — import them rather than re-deriving the launch dance. ### The in-page self-test pattern -A userscript runs assertions in the page and prints a summary the harness parses from the console. The bundled scripts in [`../../example/tests/`](../../example/tests/) do this. Most share one framework, [`../../example/tests/lib/sctest.js`](../../example/tests/lib/sctest.js) (loaded via `@require` and rewritten to a local mock server under E2E), so all of those emit the same four lines: +A userscript runs assertions in the page and prints a diagnostic summary the harness parses from the console. The +bundled scripts in [`../../example/tests/`](../../example/tests/) do this. Most share one framework, +[`../../example/tests/lib/sctest.js`](../../example/tests/lib/sctest.js) (loaded via `@require` and rewritten to a local +mock server under E2E), so all of those emit per-case diagnostics and one stable JSON marker: ``` -总测试数: 12 -通过: 12 -失败: 0 -跳过: 0 (34ms) +[SCTEST_RESULT] {"protocol":"sctest/v1","counts":{"PASS":12,"FAIL":0,"WARN":0,"INFO":0,"SKIP":0,"MANUAL":0}, ...} ``` -A script running in a background / crontab context has no visible page, so the framework additionally emits one `GM_log` entry per case with structured labels (`sctest`, `status`) — filterable chips on the 运行日志 page. Cases that need a human action (e.g. clicking a menu item) are registered with `itManual` and count as skipped until confirmed on the panel. Writing cases against the framework is [`../../example/tests/lib/README.md`](../../example/tests/lib/README.md)'s. +A script running in a background / crontab context has no visible page, so the framework additionally emits one `GM_log` +entry per case with structured labels (`sctest`, `status`) — filterable chips on the 运行日志 page. Results use `PASS`, +`FAIL`, `WARN`, `INFO`, `SKIP`, and an independent pending `MANUAL` status. Cases that need a human action (e.g. +clicking a menu item) remain `MANUAL` until confirmed on the panel; they are never silently counted as automatic passes. +Writing cases against the framework is documented in +[`../../example/tests/lib/README.md`](../../example/tests/lib/README.md). -Three scripts print no unified summary and have to be read on their own terms: [`gm_download_test.js`](../../example/tests/gm_download_test.js) and [`gm_menu_test.js`](../../example/tests/gm_menu_test.js) are self-contained runners carrying their own panel and human-confirmation flow (no `@require`), and [`gm_value_test.js`](../../example/tests/gm_value_test.js) is an interactive multi-frame dashboard demo for `GM_addValueChangeListener` with no machine-checkable assertions. +Three scripts keep specialized operation UIs and have to be read on their own terms: +[`gm_download_test.js`](../../example/tests/gm_download_test.js) retains its download panel and human-confirmation flow, +while [`gm_menu_test.js`](../../example/tests/gm_menu_test.js) and +[`gm_value_test.js`](../../example/tests/gm_value_test.js) retain their menu and multi-frame dashboard UIs. They use +`SCTest.createReportSession()` for the same console/JSON protocol; operation observations are `INFO`, pending human +decisions are `MANUAL`, and no manual action is promoted to `PASS` without an explicit verdict. -In a session there is nothing to wire up — the collector already recorded the lines, whichever context printed them (a `@background` script prints from `src/sandbox.html`, not from a page): +In a session there is nothing to wire up — the collector already recorded the lines, whichever context printed them (a +`@background` script prints from `src/sandbox.html`, not from a page). Filter the stable marker when a machine-readable +report is needed: ```bash -node e2e/drive.mjs console 200 | grep -E "(通过|Passed)[::] *[0-9]+" +node e2e/drive.mjs console 200 | grep "\\[SCTEST_RESULT\\]" ``` -In a spec, collect and assert on them — same parse as the committed `gm-api.spec.ts` harness: +In a spec, collect and assert on the marker — same parse as the committed `gm-api.spec.ts` harness. E2E fails only when +`summary.failed` is non-zero; `WARN`, `INFO`, `SKIP`, and pending `MANUAL` stay visible for diagnosis: ```ts const logs: string[] = []; -let passed = -1; -let failed = -1; +let summary: { protocol: string; passed: number; failed: number; counts: Record } | null = null; page.on("console", (msg) => { const text = msg.text(); logs.push(text); - const pass = text.match(/(通过|Passed)[::]\s*(\d+)/); - const fail = text.match(/(失败|Failed)[::]\s*(\d+)/); - if (pass) passed = parseInt(pass[2], 10); - if (fail) failed = parseInt(fail[2], 10); + if (!text.startsWith("[SCTEST_RESULT] ")) return; + summary = JSON.parse(text.slice("[SCTEST_RESULT] ".length)); }); // ...navigate to the target page, then: -expect(failed, logs.join("\n")).toBe(0); -expect(passed).toBeGreaterThan(0); +expect(summary, logs.join("\n")).not.toBeNull(); +expect(summary!.protocol).toBe("sctest/v1"); +expect(summary!.failed, logs.join("\n")).toBe(0); +expect(summary!.passed).toBeGreaterThan(0); ``` -For a new GM API, write a small self-test userscript in the same style. In a session, `node e2e/drive.mjs install ` installs it through the Service Worker and `node e2e/drive.mjs console` shows the summary the script printed; in a spec, use `installScriptByCode`. Keep the script inside the scenario directory — it is verification scaffolding, not a committed example. +For a new GM API, write a small self-test userscript in the same style. In a session, +`node e2e/drive.mjs install ` installs it through the Service Worker and `node e2e/drive.mjs console` +shows the summary the script printed; in a spec, use `installScriptByCode`. Keep the script inside the scenario +directory — it is verification scaffolding, not a committed example. ## Behaviour fired from extension UI -The self-test pattern covers only what a userscript observes in the page. Some behaviour is fired from extension UI — a `GM_registerMenuCommand` menu is triggered from the popup. Clicking that button is not drivable ([`verification-debugging.md`](verification-debugging.md#common-gotchas)); sending the message it sends is. +The self-test pattern covers only what a userscript observes in the page. Some behaviour is fired from extension UI — a +`GM_registerMenuCommand` menu is triggered from the popup. Clicking that button is not drivable +([`verification-debugging.md`](verification-debugging.md#common-gotchas)); sending the message it sends is. -Clients talk to the Service Worker via `chrome.runtime.sendMessage({ action, data })`, where `action` is `/` and the reply is wrapped as `{ code, data }` — payload is `res.data`, a truthy `code` means error ([`../../packages/message/client.ts`](../../packages/message/client.ts)). Read the tab coordinates you need (`tabId`/`frameId`/`documentId`) from a prior `getPopupData` call. +Clients talk to the Service Worker via `chrome.runtime.sendMessage({ action, data })`, where `action` is +`/` and the reply is wrapped as `{ code, data }` — payload is `res.data`, a truthy `code` means +error ([`../../packages/message/client.ts`](../../packages/message/client.ts)). Read the tab coordinates you need +(`tabId`/`frameId`/`documentId`) from a prior `getPopupData` call. ```ts // from a chrome-extension:// page (e.g. options.html); poll until the async registration shows up @@ -85,15 +111,24 @@ node e2e/drive.mjs open options node e2e/drive.mjs eval "const [tab] = await chrome.tabs.query({active:true,lastFocusedWindow:true}); if (!tab?.id || !tab.url) throw new Error('no active tab'); const r = await chrome.runtime.sendMessage({action:'serviceWorker/popup/getPopupData', data:{tabId:tab.id, url:tab.url}}); return r.data.scriptList" ``` -This drives the real SW → content → sandbox → callback path, behaviourally identical to the popup button, which discards the DOM event and calls the same message. It is a substitution: the verdict row names it and says the popup's own click path was not covered. +This drives the real SW → content → sandbox → callback path, behaviourally identical to the popup button, which discards +the DOM event and calls the same message. It is a substitution: the verdict row names it and says the popup's own click +path was not covered. ## A UI change across light and dark theme -The theme is stored in `localStorage` under `lightMode` with value `"light"` / `"dark"` / `"auto"` ([`../../src/pages/components/theme-provider.tsx`](../../src/pages/components/theme-provider.tsx), and [`../../src/pages/common.ts`](../../src/pages/common.ts), which reads the same key during pre-render to avoid a theme flash). Setting it before the page's own scripts run — `context.addInitScript` — is what applies the theme on first paint instead of flashing the default. +The theme is stored in `localStorage` under `lightMode` with value `"light"` / `"dark"` / `"auto"` +([`../../src/pages/components/theme-provider.tsx`](../../src/pages/components/theme-provider.tsx), and +[`../../src/pages/common.ts`](../../src/pages/common.ts), which reads the same key during pre-render to avoid a theme +flash). Setting it before the page's own scripts run — `context.addInitScript` — is what applies the theme on first +paint instead of flashing the default. -Confirm that timing for a `chrome-extension://` page in your own setup before relying on it: `addInitScript` timing relative to an extension page's bootstrap can differ from a normal web page. Capture one screenshot per theme as separate evidence; one theme's screenshot does not show the other renders correctly. +Confirm that timing for a `chrome-extension://` page in your own setup before relying on it: `addInitScript` timing +relative to an extension page's bootstrap can differ from a normal web page. Capture one screenshot per theme as +separate evidence; one theme's screenshot does not show the other renders correctly. -A session has no `addInitScript` hook of its own, so set the key and reload — the pre-render read in `common.ts` then picks it up before first paint: +A session has no `addInitScript` hook of its own, so set the key and reload — the pre-render read in `common.ts` then +picks it up before first paint: ```bash node e2e/drive.mjs eval "localStorage.setItem('lightMode','dark'); return location.reload()" diff --git a/docs/verification.md b/docs/verification.md index 60ebaca76..46857cd5b 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -2,22 +2,22 @@ ## When to skip this guide -Use targeted committed tests alone when they fully observe the changed logic — pure logic, parsers, utilities, -docs, comments, types, and anything the committed suite already proves. Use this guide when the behaviour -depends on cross-context wiring (Service Worker ↔ Content ↔ Inject ↔ Offscreen ↔ Sandbox) or a real Chrome API a -unit test cannot exercise, or when reproducing a runtime-only bug. It does not replace TDD. +Use targeted committed tests alone when they fully observe the changed logic — pure logic, parsers, utilities, docs, +comments, types, and anything the committed suite already proves. Use this guide when the behaviour depends on +cross-context wiring (Service Worker ↔ Content ↔ Inject ↔ Offscreen ↔ Sandbox) or a real Chrome API a unit test +cannot exercise, or when reproducing a runtime-only bug. It does not replace TDD. -Verification is not how the committed suite grows: do not run `pnpm run test:e2e` to check one thing, and do not -add an `e2e/*.spec.ts` as part of it. Promotion is a separate decision +Verification is not how the committed suite grows: do not run `pnpm run test:e2e` to check one thing, and do not add an +`e2e/*.spec.ts` as part of it. Promotion is a separate decision ([`references/develop-testing.md`](references/develop-testing.md#choosing-a-test-boundary)). ## Workflow -1. Run `pnpm run typecheck` and `pnpm test -- --run `; run `pnpm test` only when the blast radius is not - confirmed local or a gate requires it. +1. Run `pnpm run typecheck` and the focused Vitest file with `pnpm exec vitest run --no-coverage `; run the full + `pnpm test` only when the blast radius is not confirmed local or a gate requires it. 2. Build the extension with `pnpm run dev` (or `pnpm run build`). The session loads `dist/ext`, so a stale build - silently verifies old code — `session.mjs` refuses to start when `dist/ext/manifest.json` is missing, but it - cannot tell you the build is *old*. + silently verifies old code — `session.mjs` refuses to start when `dist/ext/manifest.json` is missing, but it cannot + tell you the build is _old_. 3. Start a session and drive it. Everything it produces lands in `e2e/scratch//`, which is gitignored. ```bash @@ -30,8 +30,8 @@ add an `e2e/*.spec.ts` as part of it. Promotion is a separate decision ``` 4. Before running, create `report.md` in that directory from - [`references/verification-report-template.md`](references/verification-report-template.md); update it as - evidence arrives. + [`references/verification-report-template.md`](references/verification-report-template.md); update it as evidence + arrives. 5. Record how the target was driven, deciding runtime observations, gaps and shortest user reproduction steps. `actions.log` already holds the driving record verbatim — quote it rather than reconstructing it. @@ -39,15 +39,15 @@ add an `e2e/*.spec.ts` as part of it. Promotion is a separate decision Drive the live session by default. Author a spec only when the extra cost buys something. -| To observe the target | You author | -|---|---| -| a one-off state, visual or console check, however many steps it takes | nothing — drive the session | -| a sequence that must be replayed identically, or where timing/concurrency *is* the contract | a scratch spec | -| a flow worth protecting from regression forever | a committed `e2e/*.spec.ts`, as a separate decision | +| To observe the target | You author | +| ------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| a one-off state, visual or console check, however many steps it takes | nothing — drive the session | +| a sequence that must be replayed identically, or where timing/concurrency _is_ the contract | a scratch spec | +| a flow worth protecting from regression forever | a committed `e2e/*.spec.ts`, as a separate decision | -A session survives between commands, so exploring costs one command per question instead of one edit-and-rerun -cycle per question. A spec earns its cost when the *ordering* is the thing under test — `drive.mjs` gives no -guarantee about the gap between two invocations. +A session survives between commands, so exploring costs one command per question instead of one edit-and-rerun cycle per +question. A spec earns its cost when the _ordering_ is the thing under test — `drive.mjs` gives no guarantee about the +gap between two invocations. Scratch specs still run through the scratch config, and still belong to a scenario directory: @@ -58,30 +58,31 @@ pnpm exec playwright test --config playwright.scratch.config.ts -g " ## Driving the session [`../e2e/README.md`](../e2e/README.md#8-verification-sessions) owns the command reference, and -[`references/verification-methods.md`](references/verification-methods.md) the patterns for behaviour the UI does -not expose directly — the `example/tests/` in-page self-tests, Service Worker messages, themes. What matters for -a verdict: - -- **Observe from a path the driven surface does not share.** `drive.mjs storage` reads `chrome.storage.local` - from an extension page, and `drive.mjs sw` evaluates inside the Service Worker — neither goes through the UI - you just clicked. -- **The session records continuously, from every context.** `console`, uncaught exceptions and log entries from - the Service Worker, the Offscreen document, the Sandbox (where `@background` / `@crontab` scripts run) and - every page all land in `console.log`, tagged with their origin — including output produced before you thought - to look. That is what makes a console-asserting userscript self-test observable without authoring a spec. Every - `drive.mjs` command appends to `actions.log`. +[`references/verification-methods.md`](references/verification-methods.md) the patterns for behaviour the UI does not +expose directly — the `example/tests/` in-page self-tests, Service Worker messages, themes. What matters for a verdict: + +- **Observe from a path the driven surface does not share.** `drive.mjs storage` reads `chrome.storage.local` from an + extension page, and `drive.mjs sw` evaluates inside the Service Worker — neither goes through the UI you just clicked. +- **The session records continuously, from every context.** `console`, uncaught exceptions and log entries from the + Service Worker, the Offscreen document, the Sandbox (where `@background` / `@crontab` scripts run) and every page all + land in `console.log`, tagged with their origin — including output produced before you thought to look. That is what + makes a console-asserting userscript self-test observable without authoring a spec. `example/tests/lib/sctest.js` also + emits a stable `[SCTEST_RESULT]` JSON marker containing per-case `category`, `status`, `expected`, `actual`, `detail`, + and the `PASS`/`FAIL`/`WARN`/`INFO`/`SKIP`/`MANUAL` counts. Every `drive.mjs` command appends to `actions.log`. - **Screenshots are captured while the run is alive**, into `/shots/`, numbered in capture order. -- **`sw` runs *inside* the Service Worker**, so `chrome.runtime.sendMessage` there does not reach the extension - — send those from an extension page with `drive.mjs eval`. +- **`sw` runs _inside_ the Service Worker**, so `chrome.runtime.sendMessage` there does not reach the extension — send + those from an extension page with `drive.mjs eval`. -Sessions are headless: verification must not steal desktop focus, and several worktrees verify at once. Add -`--headed` only to watch by eye. +Sessions are headless: verification must not steal desktop focus, and several worktrees verify at once. Add `--headed` +only to watch by eye. A headed run is required for operation UIs that need a real user action, such as downloads, menu +clicks, and cross-iframe listener confirmation; an unconfirmed `MANUAL` result is evidence of a pending check, not a +pass. ## Running more than one at a time -Each session takes its own kernel-allocated CDP port, its own throwaway Chrome profile and its own scenario -directory, so sessions in different worktrees — or two in the same one — do not collide. The one globally -contended resource is the port, and nothing hardcodes it. +Each session takes its own kernel-allocated CDP port, its own throwaway Chrome profile and its own scenario directory, +so sessions in different worktrees — or two in the same one — do not collide. The one globally contended resource is the +port, and nothing hardcodes it. `drive.mjs` targets the only live session automatically. With more than one live it refuses to guess: @@ -90,31 +91,30 @@ node e2e/session.mjs status # 谁还活着 node e2e/drive.mjs --scenario open popup # 多会话时必须指名 ``` -Stop what you started (`node e2e/session.mjs stop --all`); a session holds a real Chrome process open until you -do. Evidence survives the stop — only the profile and `.session.json` are removed. +Stop what you started (`node e2e/session.mjs stop --all`); a session holds a real Chrome process open until you do. +Evidence survives the stop — only the profile and `.session.json` are removed. ## Reporting honestly -For acceptance against a spec, `` is the spec slug. Extract each requirement into one verdict row and -evidence section. Verdict labels are `holds`, `does not hold`, `not observed`. +For acceptance against a spec, `` is the spec slug. Extract each requirement into one verdict row and evidence +section. Verdict labels are `holds`, `does not hold`, `not observed`. -For bug reproduction, state whether the reproduction asserts expected behaviour (red until fixed) or current -buggy behaviour (green until fixed), then turn it into a committed RED test unless -[`references/develop-testing.md`](references/develop-testing.md#when-tdd-doesnt-apply) grants the exception. -Driving a session rather than authoring a spec does not remove that test. +For bug reproduction, state whether the reproduction asserts expected behaviour (red until fixed) or current buggy +behaviour (green until fixed), then turn it into a committed RED test unless +[`references/develop-testing.md`](references/develop-testing.md#when-tdd-doesnt-apply) grants the exception. Driving a +session rather than authoring a spec does not remove that test. -Never weaken an assertion, skip a failed step or describe red as green. For background and cross-context -effects, use a specific console line or storage change; "no errors" is not evidence. Obtain authorization before -destructive or external side effects — a real cloud provider through `E2E_ONEDRIVE_TOKEN_FILE` is a real account -with real side effects — and before substituting anything for a real dependency, including driving a Service -Worker message in place of the UI that sends it. The verdict row then names what stood in and what it does not -cover. +Never weaken an assertion, skip a failed step or describe red as green. For background and cross-context effects, use a +specific console line or storage change; "no errors" is not evidence. Obtain authorization before destructive or +external side effects — a real cloud provider through `E2E_ONEDRIVE_TOKEN_FILE` is a real account with real side effects +— and before substituting anything for a real dependency, including driving a Service Worker message in place of the UI +that sends it. The verdict row then names what stood in and what it does not cover. When claiming that something did **not** happen — such as a request, write, disclosure, duplicate event, or stale -callback — either observe the forbidden channel through the relevant completion or closure window, or provide a -causal proof that execution cannot reach that side effect. A final UI value, persisted value, or absence of errors -alone is insufficient. For a negative claim, `holds` requires that closure-window observation or causal proof; -otherwise report `not observed`. +callback — either observe the forbidden channel through the relevant completion or closure window, or provide a causal +proof that execution cannot reach that side effect. A final UI value, persisted value, or absence of errors alone is +insufficient. For a negative claim, `holds` requires that closure-window observation or causal proof; otherwise report +`not observed`. ## Maintaining this route diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 7532c9df0..11b3f2e60 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -28,13 +28,56 @@ type GMApiMockServer = { close: () => Promise; }; +type SCTestStatus = "PASS" | "FAIL" | "WARN" | "INFO" | "SKIP" | "MANUAL"; + +type SCTestSummary = { + protocol: "sctest/v1"; + name: string; + total: number; + passed: number; + failed: number; + warned: number; + info: number; + skipped: number; + manual: number; + counts: Record; + overall: SCTestStatus; + suites: Array<{ + cases: Array<{ + name: string; + status: SCTestStatus; + category: string; + expected: unknown; + actual: unknown; + detail: string; + }>; + }>; +}; + +function failedCaseNames(summary: SCTestSummary): string[] { + return summary.suites.flatMap((suite) => + suite.cases.filter((item) => item.status === "FAIL").map((item) => `${item.category}: ${item.name}`) + ); +} + type SCTestBrowserApi = { skip(reason: string): never; create(options: { name: string; reporter: string }): { describe(name: string, register: () => void): void; + check( + category: string, + name: string, + predicate: () => boolean | Promise, + expected: unknown, + actual: unknown, + detail: string, + options?: { onFail?: SCTestStatus; onError?: SCTestStatus; required?: boolean } + ): void; + note(category: string, name: string, expected: unknown, actual: unknown, detail: string): void; it(name: string, run: () => void): void; + itManual(name: string, options?: { hint?: string }): void; expect(value: unknown): { toBe(expected: unknown): void }; - run(): Promise; + run(): Promise; }; }; @@ -505,7 +548,7 @@ async function runTestScript( // 所以点击后必须等**新的一次**汇总,不能沿用已有值。 beforeCollect?: (page: Page) => Promise; } -): Promise<{ passed: number; failed: number; logs: string[] }> { +): Promise<{ summary: SCTestSummary; logs: string[] }> { let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8"); code = patchScriptCode(code); if (options?.requireOrigin) code = patchRequireCode(code, options.requireOrigin); @@ -517,21 +560,21 @@ async function runTestScript( const page = await context.newPage(); const logs: string[] = []; - let passed = -1; - let failed = -1; + let summary: SCTestSummary | null = null; - // 「失败:」是汇总里最后一个被这里读取的计数行,用它计数即可判定又打完了一整组汇总。 let summaryCount = 0; page.on("console", (msg) => { const text = msg.text(); logs.push(text); - const passMatch = text.match(/(通过|Passed)[::]\s*(\d+)/); - const failMatch = text.match(/(失败|Failed)[::]\s*(\d+)/); - if (passMatch) passed = parseInt(passMatch[2], 10); - if (failMatch) { - failed = parseInt(failMatch[2], 10); + if (!text.startsWith("[SCTEST_RESULT] ")) return; + try { + const parsed = JSON.parse(text.slice("[SCTEST_RESULT] ".length)) as SCTestSummary; + if (parsed.protocol !== "sctest/v1") return; + summary = parsed; summaryCount++; + } catch { + // Keep collecting console output; the assertion below reports a missing valid summary. } }); @@ -553,13 +596,14 @@ async function runTestScript( .catch(() => undefined); } else { await expect - .poll(() => passed >= 0 && failed >= 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) + .poll(() => summary !== null, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) .toBe(true) .catch(() => undefined); } await page.close(); - return { passed, failed, logs }; + expect(summary, `No valid SCTest summary found for ${scriptFile}:\n${logs.join("\n")}`).not.toBeNull(); + return { summary: summary!, logs }; } // 设计稿统一为“运行全部”入口;旧面板若仍提供 suite 专属按钮则优先使用。 @@ -629,11 +673,19 @@ test.describe("GM API", () => { reporter: "panel", }); testRun.describe("suite", () => { - testRun.it("passing case", () => testRun.expect(1).toBe(1)); - testRun.it("failing case", () => testRun.expect(1).toBe(2)); - testRun.it("skipped case", () => - (window as typeof window & { SCTest: SCTestBrowserApi }).SCTest.skip("unsupported") + testRun.check("自动断言", "passing case", () => true, true, true, "布尔 predicate 返回 true"); + testRun.check("自动断言", "failing case", () => false, true, false, "布尔 predicate 返回 false"); + testRun.check("诊断警告", "warning case", () => false, true, false, "非阻断条件", { onFail: "WARN" }); + testRun.note("运行信息", "info case", "页面可见", "页面已加载", "记录环境观察,不产生自动断言"); + testRun.check( + "环境限制", + "skipped case", + () => (window as typeof window & { SCTest: SCTestBrowserApi }).SCTest.skip("unsupported"), + "可执行", + "未执行", + "浏览器能力不可用时保留跳过原因" ); + testRun.itManual("manual case", { hint: "确认人工可操作路径后裁决" }); }); await testRun.run(); const panel = document.getElementById("sctest-panel-host")?.shadowRoot?.querySelector(".sc-panel"); @@ -658,10 +710,16 @@ test.describe("GM API", () => { ); await host.locator('[data-sctest="filter-fail"]').click(); expect(await visibleCases()).toEqual([expect.stringContaining("failing case")]); + await host.locator('[data-sctest="filter-warn"]').click(); + expect(await visibleCases()).toEqual([expect.stringContaining("warning case")]); + await host.locator('[data-sctest="filter-info"]').click(); + expect(await visibleCases()).toEqual([expect.stringContaining("info case")]); await host.locator('[data-sctest="filter-skip"]').click(); expect(await visibleCases()).toEqual([expect.stringContaining("skipped case")]); + await host.locator('[data-sctest="filter-manual"]').click(); + expect(await visibleCases()).toEqual([expect.stringContaining("manual case")]); await host.locator('[data-sctest="filter-all"]').click(); - expect(await visibleCases()).toHaveLength(3); + expect(await visibleCases()).toHaveLength(6); const suiteGroupHidden = () => host.locator('[data-sctest="suite-row"]').evaluate((row) => (row.nextElementSibling as HTMLElement).hidden); @@ -684,10 +742,33 @@ test.describe("GM API", () => { await host.locator('[data-sctest="export-json"]').click(); const copiedReport = await page.evaluate( () => - JSON.parse((window as typeof window & { copiedJson: string }).copiedJson) as { name: string; cases: unknown[] } + JSON.parse((window as typeof window & { copiedJson: string }).copiedJson) as { + name: string; + protocol: string; + cases: unknown[]; + } ); expect(copiedReport.name).toBe("CSP panel"); - expect(copiedReport.cases).toHaveLength(3); + expect(copiedReport.protocol).toBe("sctest/v1"); + expect(copiedReport.cases).toHaveLength(6); + + await host.locator('[data-sctest="manual-pass"]').click(); + await expect + .poll(() => host.locator('[data-sctest="case-row"]').filter({ hasText: "manual case" }).textContent()) + .toContain("PASS"); + await host.locator('[data-sctest="export-json"]').click(); + const settledReport = await page.evaluate( + () => + JSON.parse((window as typeof window & { copiedJson: string }).copiedJson) as { + summary: { counts: SCTestSummary["counts"] }; + cases: Array<{ name: string; status: SCTestStatus; manualVerdict: SCTestStatus | null }>; + } + ); + expect(settledReport.summary.counts.MANUAL).toBe(0); + expect(settledReport.cases.find((item) => item.name === "manual case")).toMatchObject({ + status: "PASS", + manualVerdict: "PASS", + }); const panel = host.locator(".sc-panel"); const grip = host.locator('[data-sctest="drag-handle"]'); @@ -706,7 +787,7 @@ test.describe("GM API", () => { }); test("GM_ sync API tests (gm_api_sync_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( + const { summary, logs } = await runTestScript( context, extensionId, "gm_api_sync_test.js", @@ -715,16 +796,16 @@ test.describe("GM API", () => { { patchCode, requireOrigin: gmApiMockServer.origin } ); - console.log(`[gm_api_sync_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { + console.log(`[gm_api_sync_test]`, summary); + if (summary.failed !== 0) { console.log("[gm_api_sync_test] logs:", logs.join("\n")); } - expect(failed, "Some GM_ sync API tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); + expect(summary.failed, "Some GM_ sync API tests failed").toBe(0); + expect(summary.passed, "No test results found - script may not have run").toBeGreaterThan(0); }); test("GM.* async API tests (gm_api_async_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( + const { summary, logs } = await runTestScript( context, extensionId, "gm_api_async_test.js", @@ -733,16 +814,16 @@ test.describe("GM API", () => { { patchCode, requireOrigin: gmApiMockServer.origin } ); - console.log(`[gm_api_async_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { + console.log(`[gm_api_async_test]`, summary); + if (summary.failed !== 0) { console.log("[gm_api_async_test] logs:", logs.join("\n")); } - expect(failed, "Some GM.* async API tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); + expect(summary.failed, "Some GM.* async API tests failed").toBe(0); + expect(summary.passed, "No test results found - script may not have run").toBeGreaterThan(0); }); test("Content inject tests (inject_content_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( + const { summary, logs } = await runTestScript( context, extensionId, "inject_content_test.js", @@ -751,16 +832,16 @@ test.describe("GM API", () => { { requireOrigin: gmApiMockServer.origin } ); - console.log(`[inject_content_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { + console.log(`[inject_content_test]`, summary); + if (summary.failed !== 0) { console.log("[inject_content_test] logs:", logs.join("\n")); } - expect(failed, "Some content inject tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); + expect(summary.failed, "Some content inject tests failed").toBe(0); + expect(summary.passed, "No test results found - script may not have run").toBeGreaterThan(0); }); test("@early-start page world 脚本应在 CSP 页面的解析早期执行", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( + const { summary, logs } = await runTestScript( context, extensionId, "early_inject_page_test.js", @@ -769,13 +850,13 @@ test.describe("GM API", () => { { requireOrigin: gmApiMockServer.origin } ); - if (failed !== 0) console.log("[early_inject_page_test] logs:", logs.join("\n")); - expect(failed, "Some early page-world injection tests failed").toBe(0); - expect(passed, "No early page-world results found - script may not have run").toBeGreaterThan(0); + if (summary.failed !== 0) console.log("[early_inject_page_test] logs:", logs.join("\n")); + expect(summary.failed, "Some early page-world injection tests failed").toBe(0); + expect(summary.passed, "No early page-world results found - script may not have run").toBeGreaterThan(0); }); test("@early-start content world 脚本应在 CSP 页面的解析早期执行", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( + const { summary, logs } = await runTestScript( context, extensionId, "early_inject_content_test.js", @@ -784,13 +865,13 @@ test.describe("GM API", () => { { requireOrigin: gmApiMockServer.origin } ); - if (failed !== 0) console.log("[early_inject_content_test] logs:", logs.join("\n")); - expect(failed, "Some early content-world injection tests failed").toBe(0); - expect(passed, "No early content-world results found - script may not have run").toBeGreaterThan(0); + if (summary.failed !== 0) console.log("[early_inject_content_test] logs:", logs.join("\n")); + expect(summary.failed, "Some early content-world injection tests failed").toBe(0); + expect(summary.passed, "No early content-world results found - script may not have run").toBeGreaterThan(0); }); test("Unwrap scriptlet tests (unwrap_e2e_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( + const { summary, logs } = await runTestScript( context, extensionId, "unwrap_e2e_test.js", @@ -799,16 +880,16 @@ test.describe("GM API", () => { { requireOrigin: gmApiMockServer.origin } ); - console.log(`[unwrap_e2e_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { + console.log(`[unwrap_e2e_test]`, summary); + if (summary.failed !== 0) { console.log("[unwrap_e2e_test] logs:", logs.join("\n")); } - expect(failed, "Some unwrap scriptlet tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); + expect(summary.failed, "Some unwrap scriptlet tests failed").toBe(0); + expect(summary.passed, "No test results found - script may not have run").toBeGreaterThan(0); }); test("WindowMessage Transport Test (window_message_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( + const { summary, logs } = await runTestScript( context, extensionId, "window_message_test.js", @@ -817,16 +898,16 @@ test.describe("GM API", () => { { patchCode, requireOrigin: gmApiMockServer.origin } ); - console.log(`[window_message_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { + console.log(`[window_message_test]`, summary); + if (summary.failed !== 0) { console.log("[window_message_test] logs:", logs.join("\n")); } - expect(failed, "Some window message tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); + expect(summary.failed, "Some window message tests failed").toBe(0); + expect(summary.passed, "No test results found - script may not have run").toBeGreaterThan(0); }); test("Sandbox Test (sandbox_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( + const { summary, logs } = await runTestScript( context, extensionId, "sandbox_test.js", @@ -835,16 +916,16 @@ test.describe("GM API", () => { { requireOrigin: gmApiMockServer.origin } ); - console.log(`[sandbox_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { + console.log(`[sandbox_test]`, summary); + if (summary.failed !== 0) { console.log("[sandbox_test] logs:", logs.join("\n")); } - expect(failed, "Some sandbox tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); + expect(summary.failed, "Some sandbox tests failed").toBe(0); + expect(summary.passed, "No test results found - script may not have run").toBeGreaterThan(0); }); test("GM_xhr redirect tests (gm_xhr_redirect_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( + const { summary, logs } = await runTestScript( context, extensionId, "gm_xhr_redirect_test.js", @@ -853,16 +934,16 @@ test.describe("GM API", () => { { patchCode, requireOrigin: gmApiMockServer.origin } ); - console.log(`[gm_xhr_redirect_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { + console.log(`[gm_xhr_redirect_test]`, summary); + if (summary.failed !== 0) { console.log("[gm_xhr_redirect_test] logs:", logs.join("\n")); } - expect(failed, "Some GM_xhr redirect tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); + expect(summary.failed, "Some GM_xhr redirect tests failed").toBe(0); + expect(summary.passed, "No test results found - script may not have run").toBeGreaterThan(0); }); test("GM_xhr tests (gm_xhr_test.js)", async ({ context, extensionId }) => { - const { passed, failed, logs } = await runTestScript( + const { summary, logs } = await runTestScript( context, extensionId, "gm_xhr_test.js", @@ -876,11 +957,12 @@ test.describe("GM API", () => { } ); - console.log(`[gm_xhr_test] passed=${passed}, failed=${failed}`); - if (failed !== 0) { + console.log(`[gm_xhr_test]`, summary); + if (summary.failed !== 0) { + console.log("[gm_xhr_test] failed cases:", failedCaseNames(summary)); console.log("[gm_xhr_test] logs:", logs.join("\n")); } - expect(failed, "Some GM_xhr tests failed").toBe(0); - expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); + expect(summary.failed, "Some GM_xhr tests failed").toBe(0); + expect(summary.passed, "No test results found - script may not have run").toBeGreaterThan(0); }); }); diff --git a/example/tests/early_inject_content_test.js b/example/tests/early_inject_content_test.js index 174629254..5403ae3ae 100644 --- a/example/tests/early_inject_content_test.js +++ b/example/tests/early_inject_content_test.js @@ -22,131 +22,229 @@ // reporter: "console" — 用例本身在断言 document-start 时 DOM 应保持原始态(head/body 均不存在、 // 唯一节点 innerHTML 为空);Panel reporter 会在 run() 开始时把浮层面板挂到 document.documentElement // 下,抢在该断言之前弄脏这个待验证的原始态,所以这里必须关闭 Panel,只留 Console 通道。 -const { describe, it, expect, run } = SCTest.create({ name: "Early-start 测试(content 环境)", reporter: "console" }); +const { describe, check, expect, run } = SCTest.create({ name: "Early-start 测试(content 环境)", reporter: "console" }); // 同步测试 describe("DOM操作 API 测试", () => { - it("GM_addElement", () => { - const element = GM_addElement("div", { - textContent: "GM_addElement测试元素", - style: "display:none;", - id: "gm-test-element", - }); - expect(element !== null && element !== undefined).toBeTruthy(); - expect(element.id).toBe("gm-test-element"); - expect(element.tagName).toBe("DIV"); - // 清理测试元素 - element.parentNode.removeChild(element); - }); - - it("GM_addStyle", () => { - const styleElement = GM_addStyle(` + check( + "自动断言", + "GM_addElement", + () => { + const element = GM_addElement("div", { + textContent: "GM_addElement测试元素", + style: "display:none;", + id: "gm-test-element", + }); + expect(element !== null && element !== undefined).toBeTruthy(); + expect(element.id).toBe("gm-test-element"); + expect(element.tagName).toBe("DIV"); + // 清理测试元素 + element.parentNode.removeChild(element); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_addStyle", + () => { + const styleElement = GM_addStyle(` .gm-style-test { color: #10b981 !important; } `); - expect(styleElement !== null && styleElement !== undefined).toBeTruthy(); - expect(styleElement.tagName === "STYLE" || styleElement.sheet).toBeTruthy(); - // 清理测试样式 - styleElement.parentNode.removeChild(styleElement); - }); + expect(styleElement !== null && styleElement !== undefined).toBeTruthy(); + expect(styleElement.tagName === "STYLE" || styleElement.sheet).toBeTruthy(); + // 清理测试样式 + styleElement.parentNode.removeChild(styleElement); + }, + null, + null, + "保留原有断言体" + ); }); (async function () { "use strict"; describe("早期脚本环境检查", () => { - it("检查 document.head 不存在", () => { - console.log("document.head 存在:", !!document.head); - console.log("document.head 值:", document.head); - // 早期脚本运行时 document.head 应该不存在 - expect(document.head === null || document.head === undefined).toBeTruthy(); - }); - - it("检查 document.body 不存在", () => { - console.log("document.body 存在:", !!document.body); - console.log("document.body 值:", document.body); - // 早期脚本运行时 document.body 应该不存在 - expect(document.body === null || document.body === undefined).toBeTruthy(); - }); - - it("检查可用的DOM节点应该是HTML元素", () => { - const firstElement = document.querySelector("*"); - console.log("querySelector('*') 找到的第一个元素:", firstElement?.tagName); - expect(firstElement !== null).toBeTruthy(); - expect(firstElement.tagName).toBe("HTML"); - expect(firstElement.innerHTML).toBe(""); - }); + check( + "自动断言", + "检查 document.head 不存在", + () => { + console.log("document.head 存在:", !!document.head); + console.log("document.head 值:", document.head); + // 早期脚本运行时 document.head 应该不存在 + expect(document.head === null || document.head === undefined).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "检查 document.body 不存在", + () => { + console.log("document.body 存在:", !!document.body); + console.log("document.body 值:", document.body); + // 早期脚本运行时 document.body 应该不存在 + expect(document.body === null || document.body === undefined).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "检查可用的DOM节点应该是HTML元素", + () => { + const firstElement = document.querySelector("*"); + console.log("querySelector('*') 找到的第一个元素:", firstElement?.tagName); + expect(firstElement !== null).toBeTruthy(); + expect(firstElement.tagName).toBe("HTML"); + expect(firstElement.innerHTML).toBe(""); + }, + null, + null, + "保留原有断言体" + ); }); describe("CSP绕过测试", () => { - it("CSP绕过 - 内联脚本", () => { - const script = document.createElement("script"); - script.textContent = 'console.log("Content环境绕过CSP测试");'; - // 早期脚本运行时 document.head 和 document.body 不存在 - // 使用 querySelector("*") 查找第一个可用的元素(应该是HTML元素)进行注入 - let node = document.querySelector("*"); - expect(node !== null).toBeTruthy(); - node.appendChild(script); - expect(script.parentNode === node).toBeTruthy(); - expect(node.tagName).toBe("HTML"); - }); + check( + "自动断言", + "CSP绕过 - 内联脚本", + () => { + const script = document.createElement("script"); + script.textContent = 'console.log("Content环境绕过CSP测试");'; + // 早期脚本运行时 document.head 和 document.body 不存在 + // 使用 querySelector("*") 查找第一个可用的元素(应该是HTML元素)进行注入 + let node = document.querySelector("*"); + expect(node !== null).toBeTruthy(); + node.appendChild(script); + expect(script.parentNode === node).toBeTruthy(); + expect(node.tagName).toBe("HTML"); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM_log 测试", () => { - it("GM_log", () => { - GM_log("测试日志输出", "info", { type: "test", value: 123 }); - // GM_log本身不返回值,只要不抛出异常就算成功 - expect(true).toBeTruthy(); - }); + check( + "自动断言", + "GM_log", + () => { + GM_log("测试日志输出", "info", { type: "test", value: 123 }); + // GM_log本身不返回值,只要不抛出异常就算成功 + expect(true).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM_info 测试", () => { - it("GM_info", () => { - expect(typeof GM_info === "object").toBeTruthy(); - expect(!!GM_info.script).toBeTruthy(); - expect(!!GM_info.script.name).toBeTruthy(); - }); + check( + "自动断言", + "GM_info", + () => { + expect(typeof GM_info === "object").toBeTruthy(); + expect(!!GM_info.script).toBeTruthy(); + expect(!!GM_info.script.name).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 存储 API 测试", () => { - it("GM_setValue - 字符串", async () => { - await GM.setValue("test_key", "content环境测试值"); - const value = GM_getValue("test_key"); - expect(value).toBe("content环境测试值"); - }); - - it("GM_setValue - 数字", () => { - GM_setValue("test_number", 12345); - const value = GM_getValue("test_number"); - expect(value).toBe(12345); - }); - - it("GM_setValue - 对象", () => { - const obj = { name: "ScriptCat", type: "content" }; - GM_setValue("test_object", obj); - const value = GM_getValue("test_object", {}); - expect(value.name).toBe("ScriptCat"); - expect(value.type).toBe("content"); - }); - - it("GM_getValue - 默认值", () => { - const value = GM_getValue("non_existent_key", "默认值"); - expect(value).toBe("默认值"); - }); - - it("GM_listValues", () => { - const keys = GM_listValues(); - expect(Array.isArray(keys)).toBeTruthy(); - expect(keys.length >= 3).toBeTruthy(); - }); - - it("GM_deleteValue", () => { - GM_setValue("test_delete", "to_be_deleted"); - expect(GM_getValue("test_delete")).toBe("to_be_deleted"); - GM_deleteValue("test_delete"); - expect(GM_getValue("test_delete", null)).toBe(null); - }); + check( + "自动断言", + "GM_setValue - 字符串", + async () => { + await GM.setValue("test_key", "content环境测试值"); + const value = GM_getValue("test_key"); + expect(value).toBe("content环境测试值"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_setValue - 数字", + () => { + GM_setValue("test_number", 12345); + const value = GM_getValue("test_number"); + expect(value).toBe(12345); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_setValue - 对象", + () => { + const obj = { name: "ScriptCat", type: "content" }; + GM_setValue("test_object", obj); + const value = GM_getValue("test_object", {}); + expect(value.name).toBe("ScriptCat"); + expect(value.type).toBe("content"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_getValue - 默认值", + () => { + const value = GM_getValue("non_existent_key", "默认值"); + expect(value).toBe("默认值"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_listValues", + () => { + const keys = GM_listValues(); + expect(Array.isArray(keys)).toBeTruthy(); + expect(keys.length >= 3).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_deleteValue", + () => { + GM_setValue("test_delete", "to_be_deleted"); + expect(GM_getValue("test_delete")).toBe("to_be_deleted"); + GM_deleteValue("test_delete"); + expect(GM_getValue("test_delete", null)).toBe(null); + }, + null, + null, + "保留原有断言体" + ); }); await run(); diff --git a/example/tests/early_inject_page_test.js b/example/tests/early_inject_page_test.js index 44a6a142b..af21ac28f 100644 --- a/example/tests/early_inject_page_test.js +++ b/example/tests/early_inject_page_test.js @@ -21,161 +21,259 @@ // reporter: "console" — 用例本身在断言 document-start 时 DOM 应保持原始态(head/body 均不存在、 // 唯一节点 innerHTML 为空);Panel reporter 会在 run() 开始时把浮层面板挂到 document.documentElement // 下,抢在该断言之前弄脏这个待验证的原始态,所以这里必须关闭 Panel,只留 Console 通道。 -const { describe, it, expect, run } = SCTest.create({ name: "Early-start 测试(page 环境)", reporter: "console" }); +const { describe, check, expect, run } = SCTest.create({ name: "Early-start 测试(page 环境)", reporter: "console" }); describe("DOM操作 API 测试", () => { - it("GM_addElement", () => { - const element = GM_addElement("div", { - textContent: "GM_addElement测试元素", - style: "display:none;", - id: "gm-test-element", - }); - expect(element !== null && element !== undefined).toBeTruthy(); - expect(element.id).toBe("gm-test-element"); - expect(element.tagName).toBe("DIV"); - // 清理测试元素 - element.parentNode.removeChild(element); - }); + check( + "自动断言", + "GM_addElement", + () => { + const element = GM_addElement("div", { + textContent: "GM_addElement测试元素", + style: "display:none;", + id: "gm-test-element", + }); + expect(element !== null && element !== undefined).toBeTruthy(); + expect(element.id).toBe("gm-test-element"); + expect(element.tagName).toBe("DIV"); + // 清理测试元素 + element.parentNode.removeChild(element); + }, + null, + null, + "保留原有断言体" + ); - it("GM_addStyle", () => { - const styleElement = GM_addStyle(` + check( + "自动断言", + "GM_addStyle", + () => { + const styleElement = GM_addStyle(` .gm-style-test { color: #10b981 !important; } `); - expect(styleElement !== null && styleElement !== undefined).toBeTruthy(); - expect(styleElement.tagName === "STYLE" || styleElement.sheet).toBeTruthy(); - // 清理测试样式 - styleElement.parentNode.removeChild(styleElement); - }); + expect(styleElement !== null && styleElement !== undefined).toBeTruthy(); + expect(styleElement.tagName === "STYLE" || styleElement.sheet).toBeTruthy(); + // 清理测试样式 + styleElement.parentNode.removeChild(styleElement); + }, + null, + null, + "保留原有断言体" + ); }); (async function () { "use strict"; describe("早期脚本环境检查", () => { - it("检查 document.head 不存在", () => { - console.log("document.head 存在:", !!document.head); - console.log("document.head 值:", document.head); - // 早期脚本运行时 document.head 应该不存在 - expect(document.head === null || document.head === undefined).toBeTruthy(); - }); - - it("检查 document.body 不存在", () => { - console.log("document.body 存在:", !!document.body); - console.log("document.body 值:", document.body); - // 早期脚本运行时 document.body 应该不存在 - expect(document.body === null || document.body === undefined).toBeTruthy(); - }); - - it("检查可用的DOM节点应该是HTML元素", () => { - const firstElement = document.querySelector("*"); - console.log("querySelector('*') 找到的第一个元素:", firstElement?.tagName); - expect(firstElement !== null).toBeTruthy(); - expect(firstElement.tagName).toBe("HTML"); - expect(firstElement.innerHTML).toBe(""); - }); - - it("检查页面CSP", async () => { - console.log("开始CSP检测..."); - console.log("当前页面URL:", window.location.href); - - // 尝试插入外部script来测试CSP - console.log("\n%c测试外部script插入", "color: #3b82f6;"); - - const testScript = document.createElement("script"); - testScript.src = "data:application/javascript,window.__cspTestExternal=true;"; - testScript.id = "csp-test-external"; - - // 使用Promise等待加载结果 - const loadResult = await new Promise((resolve, reject) => { - testScript.onload = () => { - console.log("%c✓ 外部script加载成功 - 无CSP限制或已允许", "color: #ef4444;"); - resolve({ success: true, blocked: false }); - }; - - testScript.onerror = (error) => { - console.log("%c✓ 外部script加载失败 - 被CSP阻止(符合预期)", "color: #10b981;"); - console.log("CSP错误详情:", error); - resolve({ success: false, blocked: true, error }); - }; - - // 设置超时(1秒) - setTimeout(() => { - reject(new Error("Script加载超时")); - }, 1000); - - // 插入元素到DOM - console.log("正在插入script元素到DOM..."); - document.documentElement.appendChild(testScript); - console.log("script元素已同步插入DOM,等待异步加载结果..."); - }); + check( + "自动断言", + "检查 document.head 不存在", + () => { + console.log("document.head 存在:", !!document.head); + console.log("document.head 值:", document.head); + // 早期脚本运行时 document.head 应该不存在 + expect(document.head === null || document.head === undefined).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); - // 验证检测结果 - if (loadResult.blocked) { - console.log("%c✓ 页面存在CSP策略限制(符合预期)", "color: #10b981; font-weight: bold;"); - expect(true).toBeTruthy(); - } else if (loadResult.success) { - console.log("%c✗ 页面无CSP限制或已允许该资源(不符合预期)", "color: #ef4444; font-weight: bold;"); - expect(false).toBeTruthy(); - } - }); + check( + "自动断言", + "检查 document.body 不存在", + () => { + console.log("document.body 存在:", !!document.body); + console.log("document.body 值:", document.body); + // 早期脚本运行时 document.body 应该不存在 + expect(document.body === null || document.body === undefined).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "检查可用的DOM节点应该是HTML元素", + () => { + const firstElement = document.querySelector("*"); + console.log("querySelector('*') 找到的第一个元素:", firstElement?.tagName); + expect(firstElement !== null).toBeTruthy(); + expect(firstElement.tagName).toBe("HTML"); + expect(firstElement.innerHTML).toBe(""); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "检查页面CSP", + async () => { + console.log("开始CSP检测..."); + console.log("当前页面URL:", window.location.href); + + // 尝试插入外部script来测试CSP + console.log("\n%c测试外部script插入", "color: #3b82f6;"); + + const testScript = document.createElement("script"); + testScript.src = "data:application/javascript,window.__cspTestExternal=true;"; + testScript.id = "csp-test-external"; + + // 使用Promise等待加载结果 + const loadResult = await new Promise((resolve, reject) => { + testScript.onload = () => { + console.log("%c✓ 外部script加载成功 - 无CSP限制或已允许", "color: #ef4444;"); + resolve({ success: true, blocked: false }); + }; + + testScript.onerror = (error) => { + console.log("%c✓ 外部script加载失败 - 被CSP阻止(符合预期)", "color: #10b981;"); + console.log("CSP错误详情:", error); + resolve({ success: false, blocked: true, error }); + }; + + // 设置超时(1秒) + setTimeout(() => { + reject(new Error("Script加载超时")); + }, 1000); + + // 插入元素到DOM + console.log("正在插入script元素到DOM..."); + document.documentElement.appendChild(testScript); + console.log("script元素已同步插入DOM,等待异步加载结果..."); + }); + + // 验证检测结果 + if (loadResult.blocked) { + console.log("%c✓ 页面存在CSP策略限制(符合预期)", "color: #10b981; font-weight: bold;"); + expect(true).toBeTruthy(); + } else if (loadResult.success) { + console.log("%c✗ 页面无CSP限制或已允许该资源(不符合预期)", "color: #ef4444; font-weight: bold;"); + expect(false).toBeTruthy(); + } + }, + null, + null, + "保留原有断言体" + ); }); describe("GM_log 测试", () => { - it("GM_log", () => { - GM_log("测试日志输出", "info", { type: "test", value: 123 }); - // GM_log本身不返回值,只要不抛出异常就算成功 - expect(true).toBeTruthy(); - }); + check( + "自动断言", + "GM_log", + () => { + GM_log("测试日志输出", "info", { type: "test", value: 123 }); + // GM_log本身不返回值,只要不抛出异常就算成功 + expect(true).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM_info 测试", () => { - it("GM_info", () => { - expect(typeof GM_info === "object").toBeTruthy(); - expect(!!GM_info.script).toBeTruthy(); - expect(!!GM_info.script.name).toBeTruthy(); - }); + check( + "自动断言", + "GM_info", + () => { + expect(typeof GM_info === "object").toBeTruthy(); + expect(!!GM_info.script).toBeTruthy(); + expect(!!GM_info.script.name).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 存储 API 测试", () => { - it("GM_setValue - 字符串", async () => { - await GM.setValue("test_key", "早期脚本测试值"); - const value = GM_getValue("test_key"); - expect(value).toBe("早期脚本测试值"); - }); - - it("GM_setValue - 数字", () => { - GM_setValue("test_number", 12345); - const value = GM_getValue("test_number"); - expect(value).toBe(12345); - }); - - it("GM_setValue - 对象", () => { - const obj = { name: "ScriptCat", type: "early" }; - GM_setValue("test_object", obj); - const value = GM_getValue("test_object", {}); - expect(value.name).toBe("ScriptCat"); - expect(value.type).toBe("early"); - }); - - it("GM_getValue - 默认值", () => { - const value = GM_getValue("non_existent_key", "默认值"); - expect(value).toBe("默认值"); - }); - - it("GM_listValues", () => { - const keys = GM_listValues(); - expect(Array.isArray(keys)).toBeTruthy(); - expect(keys.length >= 3).toBeTruthy(); - }); - - it("GM_deleteValue", () => { - GM_setValue("test_delete", "to_be_deleted"); - expect(GM_getValue("test_delete")).toBe("to_be_deleted"); - GM_deleteValue("test_delete"); - expect(GM_getValue("test_delete", null)).toBe(null); - }); + check( + "自动断言", + "GM_setValue - 字符串", + async () => { + await GM.setValue("test_key", "早期脚本测试值"); + const value = GM_getValue("test_key"); + expect(value).toBe("早期脚本测试值"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_setValue - 数字", + () => { + GM_setValue("test_number", 12345); + const value = GM_getValue("test_number"); + expect(value).toBe(12345); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_setValue - 对象", + () => { + const obj = { name: "ScriptCat", type: "early" }; + GM_setValue("test_object", obj); + const value = GM_getValue("test_object", {}); + expect(value.name).toBe("ScriptCat"); + expect(value.type).toBe("early"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_getValue - 默认值", + () => { + const value = GM_getValue("non_existent_key", "默认值"); + expect(value).toBe("默认值"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_listValues", + () => { + const keys = GM_listValues(); + expect(Array.isArray(keys)).toBeTruthy(); + expect(keys.length >= 3).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_deleteValue", + () => { + GM_setValue("test_delete", "to_be_deleted"); + expect(GM_getValue("test_delete")).toBe("to_be_deleted"); + GM_deleteValue("test_delete"); + expect(GM_getValue("test_delete", null)).toBe(null); + }, + null, + null, + "保留原有断言体" + ); }); await run(); diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index e04473708..312ca91df 100644 --- a/example/tests/gm_api_async_test.js +++ b/example/tests/gm_api_async_test.js @@ -33,297 +33,500 @@ (async function () { "use strict"; - const { describe, it, expect, run } = SCTest.create({ name: "GM.* API 完整测试(异步)" }); + const { describe, check, expect, run } = SCTest.create({ name: "GM.* API 完整测试(异步)" }); describe("GM.info", () => { - it("GM.info 存在", async () => { - expect(GM.info).toBeTypeOf("object"); - expect(GM.info.script).toBeTruthy(); - expect(GM.info.scriptMetaStr).toBeTruthy(); - }); + check( + "自动断言", + "GM.info 存在", + async () => { + expect(GM.info).toBeTypeOf("object"); + expect(GM.info.script).toBeTruthy(); + expect(GM.info.scriptMetaStr).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 存储 API", () => { - it("GM.setValue - 字符串", async () => { - await GM.setValue("test_string", "Hello ScriptCat Async"); - const value = await GM.getValue("test_string"); - expect(value).toBe("Hello ScriptCat Async"); - }); - - it("GM.setValue - 数字", async () => { - await GM.setValue("test_number", 42); - const value = await GM.getValue("test_number"); - expect(value).toBe(42); - }); - - it("GM.setValue - 布尔值", async () => { - await GM.setValue("test_boolean", true); - const value = await GM.getValue("test_boolean"); - expect(value).toBe(true); - }); - - it("GM.setValue - 对象", async () => { - const obj = { name: "ScriptCat", version: "1.3.0", features: ["GM API", "Async"] }; - await GM.setValue("test_object", obj); - const value = await GM.getValue("test_object"); - expect(value).toBeTypeOf("object"); - expect(value.name).toBe(obj.name); - expect(value.version).toBe(obj.version); - expect(JSON.stringify(value.features)).toBe(JSON.stringify(obj.features)); - }); - - it("GM.setValue - 数组", async () => { - const arr = [1, 2, 3, "test", { key: "value" }]; - await GM.setValue("test_array", arr); - const value = await GM.getValue("test_array"); - expect(Array.isArray(value)).toBeTruthy(); - expect(value.length).toBe(arr.length); - expect(value[0]).toBe(arr[0]); - expect(value[3]).toBe(arr[3]); - expect(value[4].key).toBe(arr[4].key); - }); - - it("GM.getValue - 默认值", async () => { - const value = await GM.getValue("non_existent_key", "default_value"); - expect(value).toBe("default_value"); - }); - - it("GM.listValues", async () => { - const values = await GM.listValues(); - expect(Array.isArray(values)).toBeTruthy(); - expect(values.includes("test_string")).toBeTruthy(); - }); - - it("GM.deleteValue", async () => { - await GM.setValue("test_delete", "to be deleted"); - expect(await GM.getValue("test_delete")).toBe("to be deleted"); - await GM.deleteValue("test_delete"); - expect(await GM.getValue("test_delete", "not_found")).toBe("not_found"); - }); + check( + "自动断言", + "GM.setValue - 字符串", + async () => { + await GM.setValue("test_string", "Hello ScriptCat Async"); + const value = await GM.getValue("test_string"); + expect(value).toBe("Hello ScriptCat Async"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.setValue - 数字", + async () => { + await GM.setValue("test_number", 42); + const value = await GM.getValue("test_number"); + expect(value).toBe(42); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.setValue - 布尔值", + async () => { + await GM.setValue("test_boolean", true); + const value = await GM.getValue("test_boolean"); + expect(value).toBe(true); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.setValue - 对象", + async () => { + const obj = { name: "ScriptCat", version: "1.3.0", features: ["GM API", "Async"] }; + await GM.setValue("test_object", obj); + const value = await GM.getValue("test_object"); + expect(value).toBeTypeOf("object"); + expect(value.name).toBe(obj.name); + expect(value.version).toBe(obj.version); + expect(JSON.stringify(value.features)).toBe(JSON.stringify(obj.features)); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.setValue - 数组", + async () => { + const arr = [1, 2, 3, "test", { key: "value" }]; + await GM.setValue("test_array", arr); + const value = await GM.getValue("test_array"); + expect(Array.isArray(value)).toBeTruthy(); + expect(value.length).toBe(arr.length); + expect(value[0]).toBe(arr[0]); + expect(value[3]).toBe(arr[3]); + expect(value[4].key).toBe(arr[4].key); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.getValue - 默认值", + async () => { + const value = await GM.getValue("non_existent_key", "default_value"); + expect(value).toBe("default_value"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.listValues", + async () => { + const values = await GM.listValues(); + expect(Array.isArray(values)).toBeTruthy(); + expect(values.includes("test_string")).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.deleteValue", + async () => { + await GM.setValue("test_delete", "to be deleted"); + expect(await GM.getValue("test_delete")).toBe("to be deleted"); + await GM.deleteValue("test_delete"); + expect(await GM.getValue("test_delete", "not_found")).toBe("not_found"); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 样式 API", () => { - it("GM.addStyle - CSS字符串", async () => { - const css = ` + check( + "自动断言", + "GM.addStyle - CSS字符串", + async () => { + const css = ` .scriptcat-test-async { color: blue; font-weight: bold; } `; - const element = await GM.addStyle(css); - expect(element && element.tagName === "STYLE").toBeTruthy(); - }); + const element = await GM.addStyle(css); + expect(element && element.tagName === "STYLE").toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM.addElement", () => { - it("GM.addElement - 创建元素", async () => { - expect(GM.addElement).toBeTypeOf("function"); - - const div = await GM.addElement("div", { - textContent: "ScriptCat GM.addElement 测试", - style: "position: fixed; top: 10px; right: 10px; background: lightblue; padding: 10px; z-index: 9999;", - }); - expect(div && div.tagName === "DIV").toBeTruthy(); - - // 3秒后移除 - setTimeout(() => div.remove(), 3000); - }); + check( + "自动断言", + "GM.addElement - 创建元素", + async () => { + expect(GM.addElement).toBeTypeOf("function"); + + const div = await GM.addElement("div", { + textContent: "ScriptCat GM.addElement 测试", + style: "position: fixed; top: 10px; right: 10px; background: lightblue; padding: 10px; z-index: 9999;", + }); + expect(div && div.tagName === "DIV").toBeTruthy(); + + // 3秒后移除 + setTimeout(() => div.remove(), 3000); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 资源 API", () => { - it("GM.getResourceText", async () => { - expect(GM.getResourceText).toBeTypeOf("function"); - - const css = await GM.getResourceText("testCSS"); - expect(css).toBeTypeOf("string"); - expect(css.length).toBe(163870); - }); - - it("GM.getResourceUrl", async () => { - expect(GM.getResourceUrl).toBeTypeOf("function"); - - const url = await GM.getResourceUrl("testCSS"); - expect(url).toBeTypeOf("string"); - expect(url.startsWith("data:") || url.startsWith("blob:")).toBeTruthy(); - }); + check( + "自动断言", + "GM.getResourceText", + async () => { + expect(GM.getResourceText).toBeTypeOf("function"); + + const css = await GM.getResourceText("testCSS"); + expect(css).toBeTypeOf("string"); + expect(css.length).toBe(163870); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.getResourceUrl", + async () => { + expect(GM.getResourceUrl).toBeTypeOf("function"); + + const url = await GM.getResourceUrl("testCSS"); + expect(url).toBeTypeOf("string"); + expect(url.startsWith("data:") || url.startsWith("blob:")).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 网络请求 API", () => { - it("GM.xmlHttpRequest - GET 请求", async () => { - return new Promise((resolve, reject) => { - GM.xmlHttpRequest({ + check( + "自动断言", + "GM.xmlHttpRequest - GET 请求", + async () => { + return new Promise((resolve, reject) => { + GM.xmlHttpRequest({ + method: "GET", + url: "https://httpbingo.org/get", + timeout: 10000, + onload: (response) => { + try { + expect(response.status).toBe(200); + expect(response.responseText).toBeTruthy(); + const data = JSON.parse(response.responseText); + expect(data).toBeTypeOf("object"); + expect(data.url).toBe("https://httpbingo.org/get"); + resolve(); + } catch (error) { + reject(error); + } + }, + onerror: (error) => { + reject(new Error("请求失败: " + error)); + }, + ontimeout: () => { + reject(new Error("请求超时")); + }, + }); + }); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.xmlHttpRequest - 返回控制对象", + async () => { + const controller = GM.xmlHttpRequest({ method: "GET", url: "https://httpbingo.org/get", timeout: 10000, - onload: (response) => { - try { - expect(response.status).toBe(200); - expect(response.responseText).toBeTruthy(); - const data = JSON.parse(response.responseText); - expect(data).toBeTypeOf("object"); - expect(data.url).toBe("https://httpbingo.org/get"); - resolve(); - } catch (error) { - reject(error); - } - }, - onerror: (error) => { - reject(new Error("请求失败: " + error)); - }, - ontimeout: () => { - reject(new Error("请求超时")); - }, + onload: () => {}, + onerror: () => {}, }); - }); - }); - - it("GM.xmlHttpRequest - 返回控制对象", async () => { - const controller = GM.xmlHttpRequest({ - method: "GET", - url: "https://httpbingo.org/get", - timeout: 10000, - onload: () => {}, - onerror: () => {}, - }); - expect(controller).toBeTypeOf("object"); - expect(controller.abort).toBeTypeOf("function"); - controller.abort(); - }); + expect(controller).toBeTypeOf("object"); + expect(controller.abort).toBeTypeOf("function"); + controller.abort(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 通知 API", () => { - it("GM.notification - Promise 版本", async () => { - expect(GM.notification).toBeTypeOf("function"); - - const notificationPromise = GM.notification({ - text: "ScriptCat GM.* API 测试通知", - title: "ScriptCat 异步测试", - image: "https://scriptcat.org/logo.png", - onclick: () => { - console.log("通知被点击"); - }, - }); - - // GM.notification 可能返回 Promise 或控制对象 - if (notificationPromise && typeof notificationPromise.then === "function") { - await notificationPromise; - console.log("通知已发送(Promise 已完成)"); - } else { - console.log("通知已发送(请检查系统通知)"); - } - }); + check( + "自动断言", + "GM.notification - Promise 版本", + async () => { + expect(GM.notification).toBeTypeOf("function"); + + const notificationPromise = GM.notification({ + text: "ScriptCat GM.* API 测试通知", + title: "ScriptCat 异步测试", + image: "https://scriptcat.org/logo.png", + onclick: () => { + console.log("通知被点击"); + }, + }); + + // GM.notification 可能返回 Promise 或控制对象 + if (notificationPromise && typeof notificationPromise.then === "function") { + await notificationPromise; + console.log("通知已发送(Promise 已完成)"); + } else { + console.log("通知已发送(请检查系统通知)"); + } + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 剪贴板 API", () => { - it("GM.setClipboard", async () => { - expect(GM.setClipboard).toBeTypeOf("function"); - - await GM.setClipboard("ScriptCat GM.* API 测试文本 - " + new Date().toLocaleString()); - console.log("文本已复制到剪贴板(可以尝试粘贴验证)"); - }); + check( + "自动断言", + "GM.setClipboard", + async () => { + expect(GM.setClipboard).toBeTypeOf("function"); + + await GM.setClipboard("ScriptCat GM.* API 测试文本 - " + new Date().toLocaleString()); + console.log("文本已复制到剪贴板(可以尝试粘贴验证)"); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 标签页 API", () => { - it("GM.openInTab (不执行)", async () => { - // 不实际打开标签页,只测试函数是否存在 - expect(GM.openInTab).toBeTypeOf("function"); - }); + check( + "自动断言", + "GM.openInTab (不执行)", + async () => { + // 不实际打开标签页,只测试函数是否存在 + expect(GM.openInTab).toBeTypeOf("function"); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 菜单 API", () => { - it("GM.registerMenuCommand", async () => { - const menuId = await GM.registerMenuCommand("ScriptCat 异步测试菜单", () => { - alert("异步测试菜单被点击!"); - }); - expect(menuId !== undefined).toBeTruthy(); - }); + check( + "自动断言", + "GM.registerMenuCommand", + async () => { + const menuId = await GM.registerMenuCommand("ScriptCat 异步测试菜单", () => { + alert("异步测试菜单被点击!"); + }); + expect(menuId !== undefined).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM Cookie API", () => { - it("GM.cookie 函数存在", async () => { - expect(GM.cookie).toBeTypeOf("function"); - }); - - it("GM.cookie.set", async () => { - await GM.cookie.set({ - url: "http://example.com/cookie", - name: "scriptcat_async_test1", - value: "async_test_value_1", - }); - }); - - it("GM.cookie.set (带 domain 和 path)", async () => { - await GM.cookie.set({ - url: "http://www.example.com/", - domain: ".example.com", - path: "/path", - name: "scriptcat_async_test2", - value: "async_test_value_2", - }); - }); - - it("GM.cookie.list (by domain)", async () => { - const cookies = await GM.cookie.list({ - domain: "example.com", - }); - expect(Array.isArray(cookies)).toBeTruthy(); - expect(cookies.length >= 1).toBeTruthy(); - }); - - it("GM.cookie.list (by url)", async () => { - const cookies = await GM.cookie.list({ - url: "http://example.com/cookie", - }); - expect(Array.isArray(cookies)).toBeTruthy(); - }); - - it("GM.cookie.delete", async () => { - await GM.cookie.delete({ - url: "http://www.example.com/path", - name: "scriptcat_async_test2", - }); - }); - - it("GM.cookie - 验证删除后", async () => { - const cookies = await GM.cookie.list({ - domain: "example.com", - }); - const test2Cookie = cookies.find((c) => c.name === "scriptcat_async_test2"); - expect(!test2Cookie).toBeTruthy(); - }); + check( + "自动断言", + "GM.cookie 函数存在", + async () => { + expect(GM.cookie).toBeTypeOf("function"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.cookie.set", + async () => { + await GM.cookie.set({ + url: "http://example.com/cookie", + name: "scriptcat_async_test1", + value: "async_test_value_1", + }); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.cookie.set (带 domain 和 path)", + async () => { + await GM.cookie.set({ + url: "http://www.example.com/", + domain: ".example.com", + path: "/path", + name: "scriptcat_async_test2", + value: "async_test_value_2", + }); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.cookie.list (by domain)", + async () => { + const cookies = await GM.cookie.list({ + domain: "example.com", + }); + expect(Array.isArray(cookies)).toBeTruthy(); + expect(cookies.length >= 1).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.cookie.list (by url)", + async () => { + const cookies = await GM.cookie.list({ + url: "http://example.com/cookie", + }); + expect(Array.isArray(cookies)).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.cookie.delete", + async () => { + await GM.cookie.delete({ + url: "http://www.example.com/path", + name: "scriptcat_async_test2", + }); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM.cookie - 验证删除后", + async () => { + const cookies = await GM.cookie.list({ + domain: "example.com", + }); + const test2Cookie = cookies.find((c) => c.name === "scriptcat_async_test2"); + expect(!test2Cookie).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); // 清理所有测试 cookies - it("清理测试 cookies", async () => { - const cookies = await GM.cookie.list({ domain: "example.com" }); - const testCookies = cookies.filter((c) => c.name.startsWith("scriptcat_async_test")); - - if (testCookies.length === 0) { - return; - } - - await Promise.all( - testCookies.map((cookie) => - GM.cookie.delete({ - url: `http://${cookie.domain}${cookie.path}`, - name: cookie.name, - }) - ) - ); - }); + check( + "自动断言", + "清理测试 cookies", + async () => { + const cookies = await GM.cookie.list({ domain: "example.com" }); + const testCookies = cookies.filter((c) => c.name.startsWith("scriptcat_async_test")); + + if (testCookies.length === 0) { + return; + } + + await Promise.all( + testCookies.map((cookie) => + GM.cookie.delete({ + url: `http://${cookie.domain}${cookie.path}`, + name: cookie.name, + }) + ) + ); + }, + null, + null, + "保留原有断言体" + ); }); describe("unsafeWindow", () => { - it("unsafeWindow", async () => { - expect(unsafeWindow).toBeTypeOf("object"); - expect(unsafeWindow.document).toBe(document); - }); + check( + "自动断言", + "unsafeWindow", + async () => { + expect(unsafeWindow).toBeTypeOf("object"); + expect(unsafeWindow.document).toBe(document); + }, + null, + null, + "保留原有断言体" + ); }); describe("@require", () => { - it("jQuery 加载 (@require)", async () => { - expect(jQuery).toBeTypeOf("function"); - expect($).toBeTypeOf("function"); - }); + check( + "自动断言", + "jQuery 加载 (@require)", + async () => { + expect(jQuery).toBeTypeOf("function"); + expect($).toBeTypeOf("function"); + }, + null, + null, + "保留原有断言体" + ); }); await run(); diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index d6a424919..88d828170 100644 --- a/example/tests/gm_api_sync_test.js +++ b/example/tests/gm_api_sync_test.js @@ -37,471 +37,674 @@ (async function () { "use strict"; - const { describe, it, expect, run } = SCTest.create({ name: "GM API 完整测试(同步)" }); + const { describe, check, expect, run } = SCTest.create({ name: "GM API 完整测试(同步)" }); describe("GM_info", () => { - it("GM_info 存在", () => { - expect(GM_info).toBeTypeOf("object"); - expect(GM_info.script).toBeTruthy(); - expect(GM_info.scriptMetaStr).toBeTruthy(); - }); + check( + "自动断言", + "GM_info 存在", + () => { + expect(GM_info).toBeTypeOf("object"); + expect(GM_info.script).toBeTruthy(); + expect(GM_info.scriptMetaStr).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 存储 API", () => { - it("GM_setValue - 字符串", () => { - GM_setValue("test_string", "Hello ScriptCat"); - const value = GM_getValue("test_string"); - expect(value).toBe("Hello ScriptCat"); - }); - - it("GM_setValue - 数字", () => { - GM_setValue("test_number", 42); - const value = GM_getValue("test_number"); - expect(value).toBe(42); - }); - - it("GM_setValue - 布尔值", () => { - GM_setValue("test_boolean", true); - const value = GM_getValue("test_boolean"); - expect(value).toBe(true); - }); - - it("GM_setValue - 对象", () => { - const obj = { name: "ScriptCat", version: "1.2.0", features: ["GM API", "Background"] }; - GM_setValue("test_object", obj); - const value = GM_getValue("test_object"); - expect(value).toEqual(obj); - }); - - it("GM_setValue - 数组", () => { - const arr = [1, 2, 3, "test", { key: "value" }]; - GM_setValue("test_array", arr); - const value = GM_getValue("test_array"); - expect(value).toEqual(arr); - }); - - it("GM_getValue - 默认值", () => { - const value = GM_getValue("non_existent_key", "default_value"); - expect(value).toBe("default_value"); - }); - - it("GM_listValues", () => { - const values = GM_listValues(); - expect(Array.isArray(values)).toBeTruthy(); - expect(values.includes("test_string")).toBeTruthy(); - }); - - it("GM_deleteValue", () => { - GM_setValue("test_delete", "to be deleted"); - expect(GM_getValue("test_delete")).toBe("to be deleted"); - GM_deleteValue("test_delete"); - expect(GM_getValue("test_delete", "not_found")).toBe("not_found"); - }); - - it("GM_addValueChangeListener", () => { - return new Promise(async (resolve, reject) => { - let listenerId = null; - let timeoutId = null; - - // 设置 2 秒超时 - timeoutId = setTimeout(() => { - if (listenerId && typeof GM_removeValueChangeListener === "function") { - GM_removeValueChangeListener(listenerId); - } - reject(new Error("监听器超时:2秒内未触发值变化事件")); - }, 2000); - - // 先设置初始值,然后再添加监听器 - await GM.setValue("test_listener", "initial"); - console.log("已设置初始值: initial"); - - // 使用 setTimeout 确保初始值已完全设置 - setTimeout(() => { - // 添加监听器 - listenerId = GM_addValueChangeListener("test_listener", (name, oldValue, newValue, remote) => { - // 清除超时 - if (timeoutId) { - clearTimeout(timeoutId); + check( + "自动断言", + "GM_setValue - 字符串", + () => { + GM_setValue("test_string", "Hello ScriptCat"); + const value = GM_getValue("test_string"); + expect(value).toBe("Hello ScriptCat"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_setValue - 数字", + () => { + GM_setValue("test_number", 42); + const value = GM_getValue("test_number"); + expect(value).toBe(42); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_setValue - 布尔值", + () => { + GM_setValue("test_boolean", true); + const value = GM_getValue("test_boolean"); + expect(value).toBe(true); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_setValue - 对象", + () => { + const obj = { name: "ScriptCat", version: "1.2.0", features: ["GM API", "Background"] }; + GM_setValue("test_object", obj); + const value = GM_getValue("test_object"); + expect(value).toEqual(obj); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_setValue - 数组", + () => { + const arr = [1, 2, 3, "test", { key: "value" }]; + GM_setValue("test_array", arr); + const value = GM_getValue("test_array"); + expect(value).toEqual(arr); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_getValue - 默认值", + () => { + const value = GM_getValue("non_existent_key", "default_value"); + expect(value).toBe("default_value"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_listValues", + () => { + const values = GM_listValues(); + expect(Array.isArray(values)).toBeTruthy(); + expect(values.includes("test_string")).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_deleteValue", + () => { + GM_setValue("test_delete", "to be deleted"); + expect(GM_getValue("test_delete")).toBe("to be deleted"); + GM_deleteValue("test_delete"); + expect(GM_getValue("test_delete", "not_found")).toBe("not_found"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_addValueChangeListener", + () => { + return new Promise(async (resolve, reject) => { + let listenerId = null; + let timeoutId = null; + + // 设置 2 秒超时 + timeoutId = setTimeout(() => { + if (listenerId && typeof GM_removeValueChangeListener === "function") { + GM_removeValueChangeListener(listenerId); } + reject(new Error("监听器超时:2秒内未触发值变化事件")); + }, 2000); - // 验证参数 - try { - expect(name).toBe("test_listener"); - expect(oldValue).toBe("initial"); - expect(newValue).toBe("changed"); - expect(remote).toBe(false); + // 先设置初始值,然后再添加监听器 + await GM.setValue("test_listener", "initial"); + console.log("已设置初始值: initial"); - // 清理监听器 - if (typeof GM_removeValueChangeListener === "function") { - GM_removeValueChangeListener(listenerId); + // 使用 setTimeout 确保初始值已完全设置 + setTimeout(() => { + // 添加监听器 + listenerId = GM_addValueChangeListener("test_listener", (name, oldValue, newValue, remote) => { + // 清除超时 + if (timeoutId) { + clearTimeout(timeoutId); } - resolve(); - } catch (error) { - // 清理监听器 - if (typeof GM_removeValueChangeListener === "function") { - GM_removeValueChangeListener(listenerId); - } - reject(error); - } - }); + // 验证参数 + try { + expect(name).toBe("test_listener"); + expect(oldValue).toBe("initial"); + expect(newValue).toBe("changed"); + expect(remote).toBe(false); + + // 清理监听器 + if (typeof GM_removeValueChangeListener === "function") { + GM_removeValueChangeListener(listenerId); + } - // 验证返回的监听器 ID - const idType = typeof listenerId; - if (idType !== "number" && idType !== "string") { - clearTimeout(timeoutId); - reject(new Error(`监听器ID类型错误: 期望 number 或 string, 实际 ${idType}`)); - return; - } - console.log("监听器已注册,ID:", listenerId); + resolve(); + } catch (error) { + // 清理监听器 + if (typeof GM_removeValueChangeListener === "function") { + GM_removeValueChangeListener(listenerId); + } + reject(error); + } + }); - // 延迟后修改值触发监听器 - setTimeout(() => { - GM_setValue("test_listener", "changed"); - console.log("已修改值为: changed"); - }, 100); - }, 50); - }); - }); + // 验证返回的监听器 ID + const idType = typeof listenerId; + if (idType !== "number" && idType !== "string") { + clearTimeout(timeoutId); + reject(new Error(`监听器ID类型错误: 期望 number 或 string, 实际 ${idType}`)); + return; + } + console.log("监听器已注册,ID:", listenerId); + + // 延迟后修改值触发监听器 + setTimeout(() => { + GM_setValue("test_listener", "changed"); + console.log("已修改值为: changed"); + }, 100); + }, 50); + }); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 样式 API", () => { - it("GM_addStyle - CSS字符串", () => { - const css = ` + check( + "自动断言", + "GM_addStyle - CSS字符串", + () => { + const css = ` .scriptcat-test { color: red; font-weight: bold; } `; - const element = GM_addStyle(css); - expect(element && element.tagName === "STYLE").toBeTruthy(); - }); + const element = GM_addStyle(css); + expect(element && element.tagName === "STYLE").toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM_addElement", () => { - it("GM_addElement - 创建元素", async () => { - expect(GM_addElement).toBeTypeOf("function"); - - const div = GM_addElement("div", { - textContent: "ScriptCat GM_addElement 测试", - style: "position: fixed; top: 10px; right: 10px; background: yellow; padding: 10px; z-index: 9999;", - }); - expect(div && div.tagName === "DIV").toBeTruthy(); - console.log("添加的元素:", div); - - // 创建脚本元素测试 - const script = GM_addElement("script", { - textContent: 'window.foo = "bar";', - }); - expect(script && script.tagName === "SCRIPT").toBeTruthy(); - expect(unsafeWindow.foo).toBe("bar"); - console.log("添加的脚本元素:", script); - - document.querySelector(".container").insertBefore(script, document.querySelector(".masthead")); - - // onload 和 onerror 测试 - 插入图片元素 - let img; - await new Promise((resolve, reject) => { - img = GM_addElement(document.body, "img", { - src: "https://www.tampermonkey.net/favicon.ico", - onload: () => { - resolve(); - }, - onerror: (error) => { - reject(new Error("图片加载失败: " + error)); - }, + check( + "自动断言", + "GM_addElement - 创建元素", + async () => { + expect(GM_addElement).toBeTypeOf("function"); + + const div = GM_addElement("div", { + textContent: "ScriptCat GM_addElement 测试", + style: "position: fixed; top: 10px; right: 10px; background: yellow; padding: 10px; z-index: 9999;", }); - }); - expect(img && img.tagName === "IMG").toBeTruthy(); - - // 3秒后移除 - setTimeout(() => { - script.remove(); - div.remove(); - img.remove(); - }, 3000); - }); - }); - - describe("GM 资源 API", () => { - it("GM_getResourceText", () => { - expect(GM_getResourceText).toBeTypeOf("function"); + expect(div && div.tagName === "DIV").toBeTruthy(); + console.log("添加的元素:", div); - const css = GM_getResourceText("testCSS"); - expect(css).toBeTypeOf("string"); - expect(css.length).toBe(163870); - }); + // 创建脚本元素测试 + const script = GM_addElement("script", { + textContent: 'window.foo = "bar";', + }); + expect(script && script.tagName === "SCRIPT").toBeTruthy(); + expect(unsafeWindow.foo).toBe("bar"); + console.log("添加的脚本元素:", script); + + document.querySelector(".container").insertBefore(script, document.querySelector(".masthead")); + + // onload 和 onerror 测试 - 插入图片元素 + let img; + await new Promise((resolve, reject) => { + img = GM_addElement(document.body, "img", { + src: "https://www.tampermonkey.net/favicon.ico", + onload: () => { + resolve(); + }, + onerror: (error) => { + reject(new Error("图片加载失败: " + error)); + }, + }); + }); + expect(img && img.tagName === "IMG").toBeTruthy(); - it("GM_getResourceURL", () => { - expect(GM_getResourceURL).toBeTypeOf("function"); + // 3秒后移除 + setTimeout(() => { + script.remove(); + div.remove(); + img.remove(); + }, 3000); + }, + null, + null, + "保留原有断言体" + ); + }); - const url = GM_getResourceURL("testCSS"); - expect(url).toBeTypeOf("string"); - expect(url.startsWith("data:") || url.startsWith("blob:")).toBeTruthy(); - }); + describe("GM 资源 API", () => { + check( + "自动断言", + "GM_getResourceText", + () => { + expect(GM_getResourceText).toBeTypeOf("function"); + + const css = GM_getResourceText("testCSS"); + expect(css).toBeTypeOf("string"); + expect(css.length).toBe(163870); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_getResourceURL", + () => { + expect(GM_getResourceURL).toBeTypeOf("function"); + + const url = GM_getResourceURL("testCSS"); + expect(url).toBeTypeOf("string"); + expect(url.startsWith("data:") || url.startsWith("blob:")).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 网络请求 API", () => { - it("GM_xmlhttpRequest - GET 请求", () => { - return new Promise((resolve, reject) => { - GM_xmlhttpRequest({ - method: "GET", - url: "https://httpbingo.org/get", - timeout: 10000, - onload: (response) => { - try { - expect(response.status).toBe(200); - expect(response.responseText).toBeTruthy(); - const data = JSON.parse(response.responseText); - expect(data).toBeTypeOf("object"); - expect(data.url).toBe("https://httpbingo.org/get"); - resolve(); - } catch (error) { - reject(error); - } - }, - onerror: (error) => { - reject(new Error("请求失败: " + error)); - }, - ontimeout: () => { - reject(new Error("请求超时")); - }, + check( + "自动断言", + "GM_xmlhttpRequest - GET 请求", + () => { + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: "GET", + url: "https://httpbingo.org/get", + timeout: 10000, + onload: (response) => { + try { + expect(response.status).toBe(200); + expect(response.responseText).toBeTruthy(); + const data = JSON.parse(response.responseText); + expect(data).toBeTypeOf("object"); + expect(data.url).toBe("https://httpbingo.org/get"); + resolve(); + } catch (error) { + reject(error); + } + }, + onerror: (error) => { + reject(new Error("请求失败: " + error)); + }, + ontimeout: () => { + reject(new Error("请求超时")); + }, + }); }); - }); - }); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 通知 API", () => { - it("GM_notification", () => { - expect(GM_notification).toBeTypeOf("function"); - - GM_notification({ - text: "ScriptCat GM API 测试通知", - title: "ScriptCat 测试", - image: "https://scriptcat.org/logo.png", - onclick: () => { - console.log("通知被点击"); - }, - }); - console.log("通知已发送(请检查系统通知)"); - }); + check( + "自动断言", + "GM_notification", + () => { + expect(GM_notification).toBeTypeOf("function"); + + GM_notification({ + text: "ScriptCat GM API 测试通知", + title: "ScriptCat 测试", + image: "https://scriptcat.org/logo.png", + onclick: () => { + console.log("通知被点击"); + }, + }); + console.log("通知已发送(请检查系统通知)"); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 剪贴板 API", () => { - it("GM_setClipboard", () => { - expect(GM_setClipboard).toBeTypeOf("function"); - - GM_setClipboard("ScriptCat GM API 测试文本 - " + new Date().toLocaleString()); - console.log("文本已复制到剪贴板(可以尝试粘贴验证)"); - }); + check( + "自动断言", + "GM_setClipboard", + () => { + expect(GM_setClipboard).toBeTypeOf("function"); + + GM_setClipboard("ScriptCat GM API 测试文本 - " + new Date().toLocaleString()); + console.log("文本已复制到剪贴板(可以尝试粘贴验证)"); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 标签页 API", () => { - it("GM_openInTab (不执行)", () => { - // 不实际打开标签页,只测试函数是否存在 - expect(GM_openInTab).toBeTypeOf("function"); - }); + check( + "自动断言", + "GM_openInTab (不执行)", + () => { + // 不实际打开标签页,只测试函数是否存在 + expect(GM_openInTab).toBeTypeOf("function"); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 菜单 API", () => { - it("GM_registerMenuCommand", () => { - const menuId = GM_registerMenuCommand("ScriptCat 测试菜单", () => { - alert("测试菜单被点击!"); - }); - expect(menuId !== undefined).toBeTruthy(); - }); + check( + "自动断言", + "GM_registerMenuCommand", + () => { + const menuId = GM_registerMenuCommand("ScriptCat 测试菜单", () => { + alert("测试菜单被点击!"); + }); + expect(menuId !== undefined).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM Cookie API", () => { - it("GM_cookie 函数存在", () => { - expect(GM_cookie).toBeTypeOf("function"); - }); + check( + "自动断言", + "GM_cookie 函数存在", + () => { + expect(GM_cookie).toBeTypeOf("function"); + }, + null, + null, + "保留原有断言体" + ); // 测试 GM_cookie(action, details, callback) - it("GM_cookie - 回调风格 set", () => { - return new Promise((resolve, reject) => { - GM_cookie( - "set", - { - url: "http://example.com/cookie", - name: "scriptcat_test1", - value: "test_value_1", - }, - (error) => { - if (error) { - reject(new Error("设置 cookie 失败: " + error)); - } else { - resolve(); - } - } - ); - }); - }); - - it("GM_cookie - 回调风格 set (带 domain 和 path)", () => { - return new Promise((resolve, reject) => { - GM_cookie( - "set", - { - url: "http://www.example.com/", - domain: ".example.com", - path: "/path", - name: "scriptcat_test2", - value: "test_value_2", - }, - (error) => { - if (error) { - reject(new Error("设置 cookie 失败: " + error)); - } else { - resolve(); - } - } - ); - }); - }); - - it("GM_cookie - 回调风格 list (by domain)", () => { - return new Promise((resolve, reject) => { - GM_cookie( - "list", - { - domain: "example.com", - }, - (cookies, error) => { - if (error) { - reject(new Error("列出 cookies 失败: " + error)); - } else { - try { - expect(Array.isArray(cookies)).toBeTruthy(); - expect(cookies.length >= 1).toBeTruthy(); + check( + "自动断言", + "GM_cookie - 回调风格 set", + () => { + return new Promise((resolve, reject) => { + GM_cookie( + "set", + { + url: "http://example.com/cookie", + name: "scriptcat_test1", + value: "test_value_1", + }, + (error) => { + if (error) { + reject(new Error("设置 cookie 失败: " + error)); + } else { resolve(); - } catch (err) { - reject(err); } } - } - ); - }); - }); - - it("GM_cookie - 回调风格 list (by url)", () => { - return new Promise((resolve, reject) => { - GM_cookie( - "list", - { - url: "http://example.com/cookie", - }, - (cookies, error) => { - if (error) { - reject(new Error("列出 cookies 失败: " + error)); - } else { - try { - expect(Array.isArray(cookies)).toBeTruthy(); + ); + }); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_cookie - 回调风格 set (带 domain 和 path)", + () => { + return new Promise((resolve, reject) => { + GM_cookie( + "set", + { + url: "http://www.example.com/", + domain: ".example.com", + path: "/path", + name: "scriptcat_test2", + value: "test_value_2", + }, + (error) => { + if (error) { + reject(new Error("设置 cookie 失败: " + error)); + } else { resolve(); - } catch (err) { - reject(err); } } - } - ); - }); - }); - - it("GM_cookie - 回调风格 delete", () => { - return new Promise((resolve, reject) => { - GM_cookie( - "delete", - { - url: "http://www.example.com/path", - name: "scriptcat_test2", - }, - (error) => { - if (error) { - reject(new Error("删除 cookie 失败: " + error)); - } else { - resolve(); + ); + }); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_cookie - 回调风格 list (by domain)", + () => { + return new Promise((resolve, reject) => { + GM_cookie( + "list", + { + domain: "example.com", + }, + (cookies, error) => { + if (error) { + reject(new Error("列出 cookies 失败: " + error)); + } else { + try { + expect(Array.isArray(cookies)).toBeTruthy(); + expect(cookies.length >= 1).toBeTruthy(); + resolve(); + } catch (err) { + reject(err); + } + } } - } - ); - }); - }); - - it("GM_cookie - 验证删除后", () => { - return new Promise((resolve, reject) => { - GM_cookie( - "list", - { - domain: "example.com", - }, - (cookies, error) => { - if (error) { - reject(new Error("列出 cookies 失败: " + error)); - } else { - try { - const test2Cookie = cookies.find((c) => c.name === "scriptcat_test2"); - expect(!test2Cookie).toBeTruthy(); + ); + }); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_cookie - 回调风格 list (by url)", + () => { + return new Promise((resolve, reject) => { + GM_cookie( + "list", + { + url: "http://example.com/cookie", + }, + (cookies, error) => { + if (error) { + reject(new Error("列出 cookies 失败: " + error)); + } else { + try { + expect(Array.isArray(cookies)).toBeTruthy(); + resolve(); + } catch (err) { + reject(err); + } + } + } + ); + }); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_cookie - 回调风格 delete", + () => { + return new Promise((resolve, reject) => { + GM_cookie( + "delete", + { + url: "http://www.example.com/path", + name: "scriptcat_test2", + }, + (error) => { + if (error) { + reject(new Error("删除 cookie 失败: " + error)); + } else { resolve(); - } catch (err) { - reject(err); } } - } - ); - }); - }); - - // 清理所有测试 cookies - it("清理测试 cookies", () => { - return new Promise((resolve, reject) => { - GM_cookie("list", { domain: "example.com" }, (cookies, error) => { - if (error) { - reject(new Error("列出 cookies 失败: " + error)); - return; - } - - const testCookies = cookies.filter((c) => c.name.startsWith("scriptcat_test")); - - if (testCookies.length === 0) { - resolve(); - return; - } - - let deleteCount = 0; - testCookies.forEach((cookie, index) => { - GM_cookie( - "delete", - { - url: `http://${cookie.domain}${cookie.path}`, - name: cookie.name, - }, - (error) => { - deleteCount++; - if (error) { - console.warn(`删除 cookie ${cookie.name} 失败:`, error); - } - if (deleteCount === testCookies.length) { + ); + }); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_cookie - 验证删除后", + () => { + return new Promise((resolve, reject) => { + GM_cookie( + "list", + { + domain: "example.com", + }, + (cookies, error) => { + if (error) { + reject(new Error("列出 cookies 失败: " + error)); + } else { + try { + const test2Cookie = cookies.find((c) => c.name === "scriptcat_test2"); + expect(!test2Cookie).toBeTruthy(); resolve(); + } catch (err) { + reject(err); } } - ); + } + ); + }); + }, + null, + null, + "保留原有断言体" + ); + + // 清理所有测试 cookies + check( + "自动断言", + "清理测试 cookies", + () => { + return new Promise((resolve, reject) => { + GM_cookie("list", { domain: "example.com" }, (cookies, error) => { + if (error) { + reject(new Error("列出 cookies 失败: " + error)); + return; + } + + const testCookies = cookies.filter((c) => c.name.startsWith("scriptcat_test")); + + if (testCookies.length === 0) { + resolve(); + return; + } + + let deleteCount = 0; + testCookies.forEach((cookie, index) => { + GM_cookie( + "delete", + { + url: `http://${cookie.domain}${cookie.path}`, + name: cookie.name, + }, + (error) => { + deleteCount++; + if (error) { + console.warn(`删除 cookie ${cookie.name} 失败:`, error); + } + if (deleteCount === testCookies.length) { + resolve(); + } + } + ); + }); }); }); - }); - }); + }, + null, + null, + "保留原有断言体" + ); }); describe("unsafeWindow", () => { - it("unsafeWindow", () => { - expect(unsafeWindow).toBeTypeOf("object"); - expect(unsafeWindow.document).toBe(document); - }); + check( + "自动断言", + "unsafeWindow", + () => { + expect(unsafeWindow).toBeTypeOf("object"); + expect(unsafeWindow.document).toBe(document); + }, + null, + null, + "保留原有断言体" + ); }); describe("@require", () => { - it("jQuery 加载 (@require)", () => { - expect(jQuery).toBeTypeOf("function"); - expect($).toBeTypeOf("function"); - }); + check( + "自动断言", + "jQuery 加载 (@require)", + () => { + expect(jQuery).toBeTypeOf("function"); + expect($).toBeTypeOf("function"); + }, + null, + null, + "保留原有断言体" + ); }); await run(); diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index a3df884e0..33d6a1058 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -11,6 +11,7 @@ // @grant GM_setValue // @grant GM_getValue // @grant GM_info +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js // @connect httpbingo.org // @connect raw.githubusercontent.com // @connect cdn.jsdelivr.net @@ -73,6 +74,7 @@ const enableTool = true; (function () { "use strict"; if (!enableTool) return; + let reportSession = null; // ---------- Tiny DOM helper ---------- function h(tag, props = {}, ...children) { @@ -103,7 +105,9 @@ const enableTool = true; let p = ""; try { p = (typeof GM_getValue === "function" ? GM_getValue("dl_prefix", "") : "") || ""; - } catch { /* ignore */ } + } catch { + /* ignore */ + } if (!p) p = "scriptcat-gmdl-tests/"; if (!p.endsWith("/")) p += "/"; return p; @@ -111,12 +115,19 @@ const enableTool = true; function setPrefix(p) { try { if (typeof GM_setValue === "function") GM_setValue("dl_prefix", p); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } // Each test gets a unique tail so re-runs don't collide unless we explicitly // want them to (the conflictAction "overwrite" test reuses a fixed name). - const RUN_TAG = Date.now().toString(36) + "-" + Math.floor(Math.random() * 36 ** 4).toString(36).padStart(4, "0"); + const RUN_TAG = + Date.now().toString(36) + + "-" + + Math.floor(Math.random() * 36 ** 4) + .toString(36) + .padStart(4, "0"); function nameFor(label, ext = "bin") { return getPrefix() + RUN_TAG + "-" + label.replace(/[^a-zA-Z0-9_-]+/g, "_") + "." + ext; } @@ -124,12 +135,10 @@ const enableTool = true; // ---------- A small dataset built once, reused everywhere ---------- // 1x1 transparent PNG (67 bytes). const PNG_BYTES = new Uint8Array([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, - 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, - 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, - 0x0a, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, - 0x00, 0x03, 0x01, 0x01, 0x00, 0xae, 0xb4, 0xfa, 0x77, 0x00, 0x00, 0x00, - 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82 + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, + 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0xae, 0xb4, 0xfa, 0x77, + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); const PNG_BLOB = new Blob([PNG_BYTES], { type: "image/png" }); const TEXT_BLOB = new Blob(["hello from GM_download test harness"], { type: "text/plain" }); @@ -145,12 +154,18 @@ const enableTool = true; { id: "gmdl-test-panel", style: { - position: "fixed", bottom: "12px", right: "12px", - width: "520px", maxHeight: "78vh", overflow: "auto", + position: "fixed", + bottom: "12px", + right: "12px", + width: "520px", + maxHeight: "78vh", + overflow: "auto", zIndex: 2147483647, - background: "#111", color: "#f5f5f5", + background: "#111", + color: "#f5f5f5", font: "13px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif", - borderRadius: "10px", boxShadow: "0 12px 30px rgba(0,0,0,.4)", + borderRadius: "10px", + boxShadow: "0 12px 30px rgba(0,0,0,.4)", border: "1px solid #333", }, }, @@ -158,18 +173,41 @@ const enableTool = true; "div", { style: { - position: "sticky", top: 0, background: "#181818", - padding: "10px 12px", borderBottom: "1px solid #333", - display: "flex", alignItems: "center", gap: "8px", + position: "sticky", + top: 0, + background: "#181818", + padding: "10px 12px", + borderBottom: "1px solid #333", + display: "flex", + alignItems: "center", + gap: "8px", }, }, - h("div", { style: { flex: "1 1 auto" } }, - h("div", { style: { fontWeight: "600" } }, + h( + "div", + { style: { flex: "1 1 auto" } }, + h( + "div", + { style: { fontWeight: "600" } }, `GM_download Test Harness ${(typeof GM_info === "object" && GM_info.script && GM_info.script.version) || ""}` ), - h("div", { style: { display: "flex", flexDirection: "row", gap: "10px", marginTop: "2px", opacity: .85, flexWrap: "wrap" } }, - h("div", { style: { fontWeight: "400" } }, - `${(typeof GM_info === "object" && GM_info.scriptHandler) || "?"} ${(typeof GM_info === "object" && GM_info.version) || ""}`), + h( + "div", + { + style: { + display: "flex", + flexDirection: "row", + gap: "10px", + marginTop: "2px", + opacity: 0.85, + flexWrap: "wrap", + }, + }, + h( + "div", + { style: { fontWeight: "400" } }, + `${(typeof GM_info === "object" && GM_info.scriptHandler) || "?"} ${(typeof GM_info === "object" && GM_info.version) || ""}` + ), h("div", { id: "counts", style: { marginLeft: "auto" } }, "…") ) ), @@ -177,34 +215,69 @@ const enableTool = true; h("button", { id: "clear", style: btnStyle("#444") }, "Clear log") ), - h("div", { id: "status", style: { padding: "6px 12px", borderBottom: "1px solid #222", opacity: .9 } }, "Status: idle"), + h( + "div", + { id: "status", style: { padding: "6px 12px", borderBottom: "1px solid #222", opacity: 0.9 } }, + "Status: idle" + ), // Settings strip. - h("div", { style: { padding: "6px 12px", borderBottom: "1px solid #222", display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap" } }, - h("span", { style: { opacity: .8 } }, "Download prefix:"), + h( + "div", + { + style: { + padding: "6px 12px", + borderBottom: "1px solid #222", + display: "flex", + gap: "8px", + alignItems: "center", + flexWrap: "wrap", + }, + }, + h("span", { style: { opacity: 0.8 } }, "Download prefix:"), h("code", { id: "prefix", style: { background: "#222", padding: "2px 6px", borderRadius: "4px" } }, getPrefix()), h("button", { id: "setPrefix", style: btnStyle("#444") }, "Set prefix"), - h("span", { style: { opacity: .6, marginLeft: "auto", fontSize: "11.5px" } }, `RunTag: ${RUN_TAG}`) + h("span", { style: { opacity: 0.6, marginLeft: "auto", fontSize: "11.5px" } }, `RunTag: ${RUN_TAG}`) ), // Manual section. - h("details", + h( + "details", { id: "manualWrap", open: false, style: { padding: "0 12px 8px", borderBottom: "1px solid #222" } }, - h("summary", { style: { padding: "6px 0", cursor: "pointer", userSelect: "none" } }, "Manual tests (require human)"), - h("div", { id: "manualHint", style: { fontSize: "12px", opacity: .75, margin: "4px 0 6px" } }, + h( + "summary", + { style: { padding: "6px 0", cursor: "pointer", userSelect: "none" } }, + "Manual tests (require human)" + ), + h( + "div", + { id: "manualHint", style: { fontSize: "12px", opacity: 0.75, margin: "4px 0 6px" } }, "Each manual test waits for your verdict. Read the instructions in the log, perform the action, then click Mark Pass or Mark Fail. Skip ends the test without a verdict." ), h("div", { id: "manualButtons", style: { display: "flex", flexWrap: "wrap", gap: "6px", marginTop: "4px" } }) ), // Awaiting bar — shown only while a manual test is in flight. - h("div", { id: "awaitingWrap", style: { padding: "8px 12px", borderBottom: "1px solid #222", display: "none", background: "#1a1408" } }, - h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" } }, - h("div", { style: { flex: "1 1 auto" } }, + h( + "div", + { + id: "awaitingWrap", + style: { padding: "8px 12px", borderBottom: "1px solid #222", display: "none", background: "#1a1408" }, + }, + h( + "div", + { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" } }, + h( + "div", + { style: { flex: "1 1 auto" } }, h("div", { style: { fontWeight: "600", color: "#fbbf24" } }, "⏳ Awaiting your action"), - h("div", { id: "awaitingLabel", style: { fontSize: "12px", opacity: .85, marginTop: "2px" } }, "") + h("div", { id: "awaitingLabel", style: { fontSize: "12px", opacity: 0.85, marginTop: "2px" } }, "") + ), + h( + "div", + { id: "awaitingTimer", style: { fontSize: "12px", opacity: 0.85, fontFamily: "ui-monospace, monospace" } }, + "" ), - h("div", { id: "awaitingTimer", style: { fontSize: "12px", opacity: .85, fontFamily: "ui-monospace, monospace" } }, ""), // Optional in-flight action button (e.g. "🛑 Abort download"); tests register a handler via showAwaitingAction(). h("button", { id: "awaitingAction", style: { ...btnStyle("#0ea5e9"), display: "none" } }, ""), h("button", { id: "awaitingPass", style: btnStyle("#16a34a") }, "✓ Mark Pass"), @@ -214,22 +287,36 @@ const enableTool = true; ), // Queue. - h("details", { id: "queueWrap", open: false, style: { padding: "0 12px 6px", borderBottom: "1px solid #222" } }, + h( + "details", + { id: "queueWrap", open: false, style: { padding: "0 12px 6px", borderBottom: "1px solid #222" } }, h("summary", { style: { padding: "6px 0", cursor: "pointer", userSelect: "none" } }, "Pending auto tests"), - h("div", { - id: "queue", - style: { - fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace", - whiteSpace: "pre-wrap", opacity: .8, + h( + "div", + { + id: "queue", + style: { + fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace", + whiteSpace: "pre-wrap", + opacity: 0.8, + }, }, - }, "(none)") + "(none)" + ) ), // Live progress for currently running test. - h("div", { id: "progressWrap", style: { padding: "6px 12px", borderBottom: "1px solid #222", display: "none" } }, - h("div", { id: "progressLabel", style: { fontSize: "12px", opacity: .8, marginBottom: "4px" } }, ""), - h("div", { style: { background: "#222", height: "6px", borderRadius: "3px", overflow: "hidden" } }, - h("div", { id: "progressBar", style: { background: "#2a6df1", height: "100%", width: "0%", transition: "width .15s" } }) + h( + "div", + { id: "progressWrap", style: { padding: "6px 12px", borderBottom: "1px solid #222", display: "none" } }, + h("div", { id: "progressLabel", style: { fontSize: "12px", opacity: 0.8, marginBottom: "4px" } }, ""), + h( + "div", + { style: { background: "#222", height: "6px", borderRadius: "3px", overflow: "hidden" } }, + h("div", { + id: "progressBar", + style: { background: "#2a6df1", height: "100%", width: "0%", transition: "width .15s" }, + }) ) ), @@ -268,7 +355,7 @@ const enableTool = true; panel.querySelector("#clear").addEventListener("click", () => { $log.textContent = ""; - state.pass = state.fail = state.skip = 0; + state.pass = state.fail = state.warn = state.info = state.skip = state.manual = 0; setCounts(); setStatus("idle"); setQueue([]); @@ -292,24 +379,44 @@ const enableTool = true; } // ---------- Counters & status ---------- - const state = { pass: 0, fail: 0, skip: 0 }; + const state = { pass: 0, fail: 0, warn: 0, info: 0, skip: 0, manual: 0 }; function setCounts() { - $counts.textContent = `✅ ${state.pass} ❌ ${state.fail} ⏭️ ${state.skip}`; + $counts.textContent = `PASS ${state.pass} FAIL ${state.fail} WARN ${state.warn} INFO ${state.info} SKIP ${state.skip} MANUAL ${state.manual}`; } setCounts(); - function setStatus(text) { $status.textContent = `Status: ${text}`; } + function setStatus(text) { + $status.textContent = `Status: ${text}`; + } function setQueue(items) { $queue.textContent = items.length ? items.map((t, i) => `${i + 1}. ${t}`).join("\n") : "(none)"; } - function pass(msg) { state.pass++; setCounts(); logLine(`✅ ${escapeHtml(msg)}`); } + function pass(msg) { + state.pass++; + setCounts(); + logLine(`✅ ${escapeHtml(msg)}`); + } function fail(msg, extra) { - state.fail++; setCounts(); + state.fail++; + setCounts(); logLine( `❌ ${escapeHtml(msg)}${extra ? `
${escapeHtml(extra)}
` : ""}`, "fail" ); } - function skip(msg) { state.skip++; setCounts(); logLine(`⏭️ ${escapeHtml(msg)}`); } + function skip(msg) { + state.skip++; + setCounts(); + logLine(`⏭️ ${escapeHtml(msg)}`); + } + + function newReportSession() { + return SCTest.createReportSession({ name: "GM_download / GM.download", reporter: "console" }); + } + + function reportVerdict(session, result, status, detail, name) { + if (!result) return session.record({ category: "GM_download", name, status, detail }); + return session.update(result, status, { detail: detail || result.detail, manualVerdict: status }); + } function showProgress(label) { $progressWrap.style.display = ""; @@ -332,9 +439,14 @@ const enableTool = true; // ---------- Assertion helpers ---------- function assertEq(a, b, msg) { - if (a !== b) throw new Error(msg ? `${msg}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}` : `expected ${b}, got ${a}`); + if (a !== b) + throw new Error( + msg ? `${msg}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}` : `expected ${b}, got ${a}` + ); + } + function assertTrue(cond, msg) { + if (!cond) throw new Error(msg || "assertTrue failed"); } - function assertTrue(cond, msg) { if (!cond) throw new Error(msg || "assertTrue failed"); } function withTimeout(p, ms, label) { return new Promise((resolve, reject) => { let done = false; @@ -343,8 +455,20 @@ const enableTool = true; done = true; reject(new Error(`timed out after ${ms}ms: ${label || ""}`)); }, ms); - p.then((v) => { if (done) return; done = true; clearTimeout(t); resolve(v); }, - (e) => { if (done) return; done = true; clearTimeout(t); reject(e); }); + p.then( + (v) => { + if (done) return; + done = true; + clearTimeout(t); + resolve(v); + }, + (e) => { + if (done) return; + done = true; + clearTimeout(t); + reject(e); + } + ); }); } @@ -378,7 +502,10 @@ const enableTool = true; $awaitingWrap.style.display = "none"; $awaitingLabel.innerHTML = ""; $awaitingTimer.textContent = ""; - if (_verdictTimerId) { clearInterval(_verdictTimerId); _verdictTimerId = null; } + if (_verdictTimerId) { + clearInterval(_verdictTimerId); + _verdictTimerId = null; + } // Tear down any registered action button so it doesn't leak into the next test. $awaitingAction.style.display = "none"; $awaitingAction.textContent = ""; @@ -423,11 +550,14 @@ const enableTool = true; $awaitingAction.style.display = ""; $awaitingAction.onclick = (ev) => { ev.preventDefault(); - try { onClick(); } catch (e) { console.error("awaiting action handler threw:", e); } + try { + onClick(); + } catch (e) { + console.error("awaiting action handler threw:", e); + } }; } - // ---------- GM_download wrappers ---------- /** @@ -443,31 +573,49 @@ const enableTool = true; function gmDownloadCb(details) { const progress = []; let resolve, reject; - const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); let saveCancelled = false; const opts = { ...details, onprogress(p) { progress.push(p); - try { details.onprogress && details.onprogress(p); } catch {} + try { + details.onprogress && details.onprogress(p); + } catch {} updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1); }, onload(data) { - try { details.onload && details.onload(data); } catch {} + try { + details.onload && details.onload(data); + } catch {} resolve({ kind: saveCancelled ? "save_cancelled" : "load", data }); }, onerror(err) { - try { details.onerror && details.onerror(err); } catch {} + try { + details.onerror && details.onerror(err); + } catch {} reject({ kind: "error", err }); }, ontimeout(err) { - try { details.ontimeout && details.ontimeout(err); } catch {} + try { + details.ontimeout && details.ontimeout(err); + } catch {} reject({ kind: "timeout", err }); }, }; // GM_download returns { abort } in both TM and SC. const handle = GM_download(opts); - return { promise, handle, progress, _markSaveCancelled() { saveCancelled = true; } }; + return { + promise, + handle, + progress, + _markSaveCancelled() { + saveCancelled = true; + }, + }; } /** @@ -480,7 +628,9 @@ const enableTool = true; ...details, onprogress(p) { progress.push(p); - try { details.onprogress && details.onprogress(p); } catch {} + try { + details.onprogress && details.onprogress(p); + } catch {} updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1); }, }; @@ -492,8 +642,12 @@ const enableTool = true; // Each entry: { name, manual?: boolean, run: async () => void } const tests = []; - function autoTest(name, run) { tests.push({ name, manual: false, run }); } - function manualTest(name, run) { tests.push({ name, manual: true, run }); } + function autoTest(name, run) { + tests.push({ name, manual: false, run }); + } + function manualTest(name, run) { + tests.push({ name, manual: true, run }); + } // 1) sanity: APIs exist autoTest("APIs exist (GM_download / GM.download)", async () => { @@ -506,18 +660,24 @@ const enableTool = true; const name = nameFor("string-form", "txt"); const blobUrl = URL.createObjectURL(TEXT_BLOB); try { - const result = await withTimeout(new Promise((resolve, reject) => { - // String form has no callbacks, so we can only check that it does not throw - // synchronously and returns an object with abort(). The actual download is - // observed by the user. - let h; - try { - h = GM_download(blobUrl, name); - } catch (e) { return reject(e); } - assertTrue(h && typeof h.abort === "function", "handle.abort must be a function"); - // Wait briefly to give the SW a chance to dispatch the download. - setTimeout(() => resolve({ handle: h }), 800); - }), 5000, "string-form"); + const result = await withTimeout( + new Promise((resolve, reject) => { + // String form has no callbacks, so we can only check that it does not throw + // synchronously and returns an object with abort(). The actual download is + // observed by the user. + let h; + try { + h = GM_download(blobUrl, name); + } catch (e) { + return reject(e); + } + assertTrue(h && typeof h.abort === "function", "handle.abort must be a function"); + // Wait briefly to give the SW a chance to dispatch the download. + setTimeout(() => resolve({ handle: h }), 800); + }), + 5000, + "string-form" + ); assertTrue(!!result, "completed"); } finally { URL.revokeObjectURL(blobUrl); @@ -590,19 +750,25 @@ const enableTool = true; autoTest("downloadMode 'native' — xhr fetch, onprogress fires", async () => { const name = nameFor("mode-native", "bin"); const t0 = performance.now(); - const r = await withTimeout(new Promise((resolve, reject) => { - const h = GM_download({ - url: `${HB}/bytes/4096`, - name, - downloadMode: "native", - onprogress(p) { /* captured in wrapper too, but native mode emits >=1 */ }, - onload: resolve, - onerror: reject, - ontimeout: reject, - }); - // Don't keep the handle around — but ensure abort exists. - if (!h || typeof h.abort !== "function") reject(new Error("handle.abort missing")); - }), 20000, "native mode"); + const r = await withTimeout( + new Promise((resolve, reject) => { + const h = GM_download({ + url: `${HB}/bytes/4096`, + name, + downloadMode: "native", + onprogress(p) { + /* captured in wrapper too, but native mode emits >=1 */ + }, + onload: resolve, + onerror: reject, + ontimeout: reject, + }); + // Don't keep the handle around — but ensure abort exists. + if (!h || typeof h.abort !== "function") reject(new Error("handle.abort missing")); + }), + 20000, + "native mode" + ); assertTrue(!!r, "onload received"); // Sanity bound: 4KB shouldn't take 20s on any sane net. assertTrue(performance.now() - t0 < 20000, "completed in time"); @@ -611,16 +777,20 @@ const enableTool = true; // 9) browser mode — chrome.downloads only autoTest("downloadMode 'browser' — direct chrome.downloads", async () => { const name = nameFor("mode-browser", "bin"); - const r = await withTimeout(new Promise((resolve, reject) => { - GM_download({ - url: `${HB}/bytes/2048`, - name, - downloadMode: "browser", - onload: resolve, - onerror: reject, - ontimeout: reject, - }); - }), 20000, "browser mode"); + const r = await withTimeout( + new Promise((resolve, reject) => { + GM_download({ + url: `${HB}/bytes/2048`, + name, + downloadMode: "browser", + onload: resolve, + onerror: reject, + ontimeout: reject, + }); + }), + 20000, + "browser mode" + ); assertTrue(!!r, "onload received"); }); @@ -628,16 +798,22 @@ const enableTool = true; autoTest("onprogress event shape (native mode)", async () => { const name = nameFor("progress-shape", "bin"); const progresses = []; - await withTimeout(new Promise((resolve, reject) => { - GM_download({ - url: `${HB}/bytes/1024`, - name, - downloadMode: "native", - onprogress(p) { progresses.push(p); }, - onload: resolve, - onerror: reject, - }); - }), 20000, "progress shape"); + await withTimeout( + new Promise((resolve, reject) => { + GM_download({ + url: `${HB}/bytes/1024`, + name, + downloadMode: "native", + onprogress(p) { + progresses.push(p); + }, + onload: resolve, + onerror: reject, + }); + }), + 20000, + "progress shape" + ); assertTrue(progresses.length > 0, "got at least one progress event"); const last = progresses[progresses.length - 1]; assertTrue("loaded" in last, "progress has loaded"); @@ -650,25 +826,33 @@ const enableTool = true; // Note: we intentionally do NOT include RUN_TAG so the second run targets // the same path. uniquify would otherwise produce filename(1), filename(2)... const fixedName = getPrefix() + "overwrite-target.txt"; - const a = await withTimeout(new Promise((resolve, reject) => { - GM_download({ - url: URL.createObjectURL(new Blob(["v1"])), - name: fixedName, - conflictAction: "overwrite", - onload: resolve, - onerror: reject, - }); - }), 10000, "overwrite #1"); + const a = await withTimeout( + new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(new Blob(["v1"])), + name: fixedName, + conflictAction: "overwrite", + onload: resolve, + onerror: reject, + }); + }), + 10000, + "overwrite #1" + ); assertTrue(!!a, "first write succeeded"); - const b = await withTimeout(new Promise((resolve, reject) => { - GM_download({ - url: URL.createObjectURL(new Blob(["v2"])), - name: fixedName, - conflictAction: "overwrite", - onload: resolve, - onerror: reject, - }); - }), 10000, "overwrite #2"); + const b = await withTimeout( + new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(new Blob(["v2"])), + name: fixedName, + conflictAction: "overwrite", + onload: resolve, + onerror: reject, + }); + }), + 10000, + "overwrite #2" + ); assertTrue(!!b, "second write succeeded (overwrite)"); skip(`(visual check) ${fixedName} should now contain "v2"`); }); @@ -676,24 +860,32 @@ const enableTool = true; // 12) conflictAction "uniquify" — second download gets " (1)" suffix autoTest("conflictAction 'uniquify' — second write gets suffix", async () => { const fixedName = getPrefix() + "uniquify-target.txt"; - await withTimeout(new Promise((resolve, reject) => { - GM_download({ - url: URL.createObjectURL(new Blob(["v1"])), - name: fixedName, - conflictAction: "uniquify", - onload: resolve, - onerror: reject, - }); - }), 10000, "uniquify #1"); - await withTimeout(new Promise((resolve, reject) => { - GM_download({ - url: URL.createObjectURL(new Blob(["v2"])), - name: fixedName, - conflictAction: "uniquify", - onload: resolve, - onerror: reject, - }); - }), 10000, "uniquify #2"); + await withTimeout( + new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(new Blob(["v1"])), + name: fixedName, + conflictAction: "uniquify", + onload: resolve, + onerror: reject, + }); + }), + 10000, + "uniquify #1" + ); + await withTimeout( + new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(new Blob(["v2"])), + name: fixedName, + conflictAction: "uniquify", + onload: resolve, + onerror: reject, + }); + }), + 10000, + "uniquify #2" + ); skip(`(visual check) you should see both uniquify-target.txt and uniquify-target (1).txt`); }); @@ -705,16 +897,20 @@ const enableTool = true; // on disk are not addressable. Cheap alternative: just verify the download // succeeded with custom headers attached and didn't error. const name = nameFor("headers-passthrough", "json"); - const r = await withTimeout(new Promise((resolve, reject) => { - GM_download({ - url: `${HB}/headers`, - name, - downloadMode: "native", - headers: { "X-Custom-Probe": "scriptcat-gmdl-test" }, - onload: resolve, - onerror: reject, - }); - }), 20000, "headers passthrough"); + const r = await withTimeout( + new Promise((resolve, reject) => { + GM_download({ + url: `${HB}/headers`, + name, + downloadMode: "native", + headers: { "X-Custom-Probe": "scriptcat-gmdl-test" }, + onload: resolve, + onerror: reject, + }); + }), + 20000, + "headers passthrough" + ); assertTrue(!!r, "onload received"); skip(`(visual check) open ${name} — X-Custom-Probe should be echoed in the body`); }); @@ -722,13 +918,18 @@ const enableTool = true; // 14) abort() immediately — should not produce a file autoTest("abort() immediately — no onload, no onerror reached", async () => { const name = nameFor("abort-immediate", "bin"); - let onloadCalled = false, onerrorCalled = false; + let onloadCalled = false, + onerrorCalled = false; const h = GM_download({ url: `${HB}/bytes/65536`, name, downloadMode: "native", - onload() { onloadCalled = true; }, - onerror() { onerrorCalled = true; }, + onload() { + onloadCalled = true; + }, + onerror() { + onerrorCalled = true; + }, }); h.abort(); // Give the system 1.5s to (not) call any callbacks. @@ -745,14 +946,18 @@ const enableTool = true; autoTest("abort() after onload — safe no-op", async () => { const name = nameFor("abort-after-load", "txt"); let handle; - await withTimeout(new Promise((resolve, reject) => { - handle = GM_download({ - url: URL.createObjectURL(TEXT_BLOB), - name, - onload: resolve, - onerror: reject, - }); - }), 10000, "abort-after-load"); + await withTimeout( + new Promise((resolve, reject) => { + handle = GM_download({ + url: URL.createObjectURL(TEXT_BLOB), + name, + onload: resolve, + onerror: reject, + }); + }), + 10000, + "abort-after-load" + ); try { handle.abort(); } catch (e) { @@ -770,8 +975,14 @@ const enableTool = true; url: "https://blocked-host-not-in-connect.example/", name, downloadMode: "native", - onload() { onloadCalled = true; resolve(); }, - onerror(e) { errSeen = e || true; resolve(); }, + onload() { + onloadCalled = true; + resolve(); + }, + onerror(e) { + errSeen = e || true; + resolve(); + }, }); // Safety timeout setTimeout(resolve, 8000); @@ -783,18 +994,28 @@ const enableTool = true; // 17) bad URL string — onerror or thrown autoTest("invalid URL — onerror (no crash)", async () => { const name = nameFor("bad-url", "bin"); - let onloadCalled = false, errSeen = null, threw = null; + let onloadCalled = false, + errSeen = null, + threw = null; try { await new Promise((resolve) => { GM_download({ url: "not-a-real-url://??", name, - onload() { onloadCalled = true; resolve(); }, - onerror(e) { errSeen = e || true; resolve(); }, + onload() { + onloadCalled = true; + resolve(); + }, + onerror(e) { + errSeen = e || true; + resolve(); + }, }); setTimeout(resolve, 4000); }); - } catch (e) { threw = e; } + } catch (e) { + threw = e; + } assertEq(onloadCalled, false, "onload must NOT fire on bad URL"); assertTrue(errSeen != null || threw != null, "either onerror fires or it throws"); }); @@ -802,18 +1023,28 @@ const enableTool = true; // 18) empty URL — should be rejected autoTest("empty URL — onerror or thrown", async () => { const name = nameFor("empty-url", "bin"); - let onloadCalled = false, errSeen = null, threw = null; + let onloadCalled = false, + errSeen = null, + threw = null; try { await new Promise((resolve) => { GM_download({ url: "", name, - onload() { onloadCalled = true; resolve(); }, - onerror(e) { errSeen = e || true; resolve(); }, + onload() { + onloadCalled = true; + resolve(); + }, + onerror(e) { + errSeen = e || true; + resolve(); + }, }); setTimeout(resolve, 3000); }); - } catch (e) { threw = e; } + } catch (e) { + threw = e; + } assertEq(onloadCalled, false, "onload must NOT fire on empty URL"); assertTrue(errSeen != null || threw != null, "either onerror fires or it throws"); }); @@ -821,14 +1052,18 @@ const enableTool = true; // 19) name with subdirectories — folder is created under Downloads/ autoTest("name with subdirectories — nested folder created", async () => { const name = nameFor("nested/a/b/file", "txt"); - const r = await withTimeout(new Promise((resolve, reject) => { - GM_download({ - url: URL.createObjectURL(TEXT_BLOB), - name, - onload: resolve, - onerror: reject, - }); - }), 10000, "nested name"); + const r = await withTimeout( + new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(TEXT_BLOB), + name, + onload: resolve, + onerror: reject, + }); + }), + 10000, + "nested name" + ); assertTrue(!!r, "onload received"); skip(`(visual check) ${name} should exist with nested folders`); }); @@ -837,28 +1072,44 @@ const enableTool = true; autoTest("name with illegal characters — sanitized, no crash", async () => { // backend cleanFileName() replaces these. We don't know the exact // replacement but at least the download must succeed. - const rawName = getPrefix() + RUN_TAG + "-illegal<>:\"|?*-chars.txt"; - const r = await withTimeout(new Promise((resolve, reject) => { - GM_download({ - url: URL.createObjectURL(TEXT_BLOB), - name: rawName, - onload: resolve, - onerror: reject, - }); - }), 10000, "illegal chars"); + const rawName = getPrefix() + RUN_TAG + '-illegal<>:"|?*-chars.txt'; + const r = await withTimeout( + new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(TEXT_BLOB), + name: rawName, + onload: resolve, + onerror: reject, + }); + }), + 10000, + "illegal chars" + ); assertTrue(!!r, "onload received — name was sanitized"); }); // 21) GM.download rejection — invalid URL should reject the promise autoTest("GM.download promise rejects on invalid URL", async () => { - let rejected = null, resolved = null; + let rejected = null, + resolved = null; try { const p = GM.download({ url: "https://blocked-host-not-in-connect-2.example/", name: nameFor("promise-reject", "bin"), }); // Race with timeout - await withTimeout(p.then(v => { resolved = v; }, e => { rejected = e; }), 8000, "promise-reject"); + await withTimeout( + p.then( + (v) => { + resolved = v; + }, + (e) => { + rejected = e; + } + ), + 8000, + "promise-reject" + ); } catch (e) { // withTimeout firing is acceptable too — counts as "did not resolve" rejected = e; @@ -891,16 +1142,25 @@ const enableTool = true; url: blobUrl, name, saveAs: true, - onload: (d) => { events.push(["onload", d]); logLine(`→ event: onload ${JSON.stringify(d)}`); }, - onerror: (e) => { events.push(["onerror", e]); logLine(`→ event: onerror ${JSON.stringify(e)}`); }, + onload: (d) => { + events.push(["onload", d]); + logLine(`→ event: onload ${JSON.stringify(d)}`); + }, + onerror: (e) => { + events.push(["onerror", e]); + logLine(`→ event: onerror ${JSON.stringify(e)}`); + }, onprogress: (p) => events.push(["onprogress", p]), }); const v = await awaitVerdict("Save the file when the dialog appears, then click Mark Pass.", 180); URL.revokeObjectURL(blobUrl); - if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (events: ${events.map((e) => e[0]).join(", ") || "none"})`); - if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (events: ${events.map((e) => e[0]).join(", ") || "none"})`); + if (v.verdict === "skip") + throw new Error(`SKIP: ${v.reason || "no reason"} (events: ${events.map((e) => e[0]).join(", ") || "none"})`); + if (v.verdict === "fail") + throw new Error(`user said FAIL: ${v.reason} (events: ${events.map((e) => e[0]).join(", ") || "none"})`); // Pass — but if zero callbacks fired we want the human to see that too. - if (events.length === 0) logLine(`note: no callbacks fired — Pass accepted but worth checking`); + if (events.length === 0) + logLine(`note: no callbacks fired — Pass accepted but worth checking`); }); manualTest("Cancel saveAs dialog — must NOT be onerror", async () => { @@ -909,26 +1169,41 @@ const enableTool = true; logLine(`→ Expected: a Save As dialog appears. Click Cancel.`); logLine(`→ The contract: onload may fire (compat layer maps save_cancelled → onload),`); logLine(`   but onerror MUST NOT fire.`); - let sawOnerror = false, sawOnload = false; + let sawOnerror = false, + sawOnload = false; const events = []; const blobUrl = URL.createObjectURL(TEXT_BLOB); GM_download({ url: blobUrl, name, saveAs: true, - onload: (d) => { sawOnload = true; events.push("onload"); logLine(`→ event: onload ${JSON.stringify(d)}`); }, - onerror: (e) => { sawOnerror = true; events.push("onerror"); logLine(`→ event: onerror ${JSON.stringify(e)}`); }, + onload: (d) => { + sawOnload = true; + events.push("onload"); + logLine(`→ event: onload ${JSON.stringify(d)}`); + }, + onerror: (e) => { + sawOnerror = true; + events.push("onerror"); + logLine(`→ event: onerror ${JSON.stringify(e)}`); + }, }); const v = await awaitVerdict( "When the Save As dialog appears, click Cancel. Watch the log: if you see onerror, click Mark Fail; otherwise Mark Pass.", 180 ); URL.revokeObjectURL(blobUrl); - if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (sawOnload=${sawOnload}, sawOnerror=${sawOnerror})`); - if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (sawOnload=${sawOnload}, sawOnerror=${sawOnerror})`); + if (v.verdict === "skip") + throw new Error(`SKIP: ${v.reason || "no reason"} (sawOnload=${sawOnload}, sawOnerror=${sawOnerror})`); + if (v.verdict === "fail") + throw new Error(`user said FAIL: ${v.reason} (sawOnload=${sawOnload}, sawOnerror=${sawOnerror})`); // Verdict was pass — sanity-check it against what we actually observed. - if (sawOnerror) throw new Error("you marked Pass but onerror fired — that's the regression this test guards against"); - if (!sawOnload && !sawOnerror) logLine(`note: neither onload nor onerror fired — implementation may swallow the cancel silently`); + if (sawOnerror) + throw new Error("you marked Pass but onerror fired — that's the regression this test guards against"); + if (!sawOnload && !sawOnerror) + logLine( + `note: neither onload nor onerror fired — implementation may swallow the cancel silently` + ); }); // Note on cancel testing: @@ -951,7 +1226,10 @@ const enableTool = true; logLine(`→ A 100 MB download (native mode) starts. Wait until you see onprogress events streaming.`); logLine(`→ Click the 🛑 Abort download button. Then Mark Pass.`); logLine(`→ Contract: after abort(), no onload and no onerror should fire.`); - let sawOnload = false, sawOnerror = false, lastProgress = null, abortCalledAt = 0; + let sawOnload = false, + sawOnerror = false, + lastProgress = null, + abortCalledAt = 0; const handle = GM_download({ url, name, @@ -960,20 +1238,32 @@ const enableTool = true; lastProgress = p; updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1); }, - onload: (d) => { sawOnload = true; logLine(`→ event: onload AFTER ABORT — regression: ${JSON.stringify(d)}`); }, + onload: (d) => { + sawOnload = true; + logLine(`→ event: onload AFTER ABORT — regression: ${JSON.stringify(d)}`); + }, onerror: (e) => { // Some implementations DO surface onerror on abort. We log it but don't fail on that alone. sawOnerror = true; - const sinceAbort = abortCalledAt ? `${(performance.now() - abortCalledAt) | 0}ms after abort()` : "BEFORE abort() — that's a different bug"; + const sinceAbort = abortCalledAt + ? `${(performance.now() - abortCalledAt) | 0}ms after abort()` + : "BEFORE abort() — that's a different bug"; logLine(`→ event: onerror (${sinceAbort}): ${JSON.stringify(e)}`); }, }); showProgress("downloading 100 MB (abort me)"); showAwaitingAction("🛑 Abort download", () => { - if (abortCalledAt) { logLine("→ abort already requested"); return; } + if (abortCalledAt) { + logLine("→ abort already requested"); + return; + } abortCalledAt = performance.now(); logLine(`→ calling handle.abort()`); - try { handle.abort(); } catch (e) { logLine(`abort threw: ${escapeHtml(String(e))}`); } + try { + handle.abort(); + } catch (e) { + logLine(`abort threw: ${escapeHtml(String(e))}`); + } }); const v = await awaitVerdict( "Wait for progress events, click 🛑 Abort download, then Mark Pass. (Mark Fail if onload fires after abort.)", @@ -983,44 +1273,64 @@ const enableTool = true; const ctx = `aborted=${!!abortCalledAt}, sawOnload=${sawOnload}, sawOnerror=${sawOnerror}, lastProgress=${lastProgress ? `${lastProgress.loaded}/${lastProgress.total}` : "none"}`; if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (${ctx})`); if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (${ctx})`); - if (!abortCalledAt) logLine(`note: you marked Pass without clicking Abort — test was inconclusive`); + if (!abortCalledAt) + logLine(`note: you marked Pass without clicking Abort — test was inconclusive`); // The strong invariant: no successful onload after the user asked for abort. if (sawOnload && abortCalledAt) throw new Error(`onload fired after abort — cancel was not honored (${ctx})`); }); - manualTest("Cancel via chrome://downloads (browser mode) — must arrive as onload (save_cancelled), NOT onerror", async () => { - const name = nameFor("manual-cancel-inprogress-browser", "bin"); - // browser mode hands the HTTP fetch to chrome.downloads itself, so the - // entry shows up in chrome://downloads with a real progress bar and a - // working Cancel button. 100 MB on plain HTTP from thinkbroadband gives - // a few seconds of real network time on most connections. - const url = `http://ipv4.download.thinkbroadband.com/100MB.zip?t=${Date.now()}`; - logLine(`▶ Manual #3b: ${escapeHtml(name)}`); - logLine(`→ A 100 MB download (browser mode) starts — chrome://downloads will show it with a real progress bar.`); - logLine(`→ Open chrome://downloads, find the entry, click Cancel.`); - logLine(`→ Contract: SC treats user-cancel as save_cancelled and routes it to onload, NOT onerror.`); - let sawOnload = false, sawOnerror = false, onloadData = null; - GM_download({ - url, - name, - downloadMode: "browser", - onprogress: (p) => updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1), - onload: (d) => { sawOnload = true; onloadData = d; logLine(`→ event: onload ${JSON.stringify(d)}`); }, - onerror: (e) => { sawOnerror = true; logLine(`→ event: onerror ${JSON.stringify(e)}`); }, - }); - showProgress("downloading 100 MB (cancel from chrome://downloads)"); - const v = await awaitVerdict( - "Cancel the download from chrome://downloads, then Mark Pass if you saw onload (and no onerror).", - 300 - ); - hideProgress(); - const ctx = `sawOnload=${sawOnload}, sawOnerror=${sawOnerror}, onloadData=${JSON.stringify(onloadData)}`; - if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (${ctx})`); - if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (${ctx})`); - // The exact regression this guards: onerror on user-cancel is the bug evaluated above. - if (sawOnerror) throw new Error(`onerror fired on user-cancel — that's the save_cancelled regression (${ctx})`); - if (!sawOnload) logLine(`note: neither onload nor onerror fired — did you actually cancel? Marking Pass anyway because user said so.`); - }); + manualTest( + "Cancel via chrome://downloads (browser mode) — must arrive as onload (save_cancelled), NOT onerror", + async () => { + const name = nameFor("manual-cancel-inprogress-browser", "bin"); + // browser mode hands the HTTP fetch to chrome.downloads itself, so the + // entry shows up in chrome://downloads with a real progress bar and a + // working Cancel button. 100 MB on plain HTTP from thinkbroadband gives + // a few seconds of real network time on most connections. + const url = `http://ipv4.download.thinkbroadband.com/100MB.zip?t=${Date.now()}`; + logLine(`▶ Manual #3b: ${escapeHtml(name)}`); + logLine( + `→ A 100 MB download (browser mode) starts — chrome://downloads will show it with a real progress bar.` + ); + logLine(`→ Open chrome://downloads, find the entry, click Cancel.`); + logLine( + `→ Contract: SC treats user-cancel as save_cancelled and routes it to onload, NOT onerror.` + ); + let sawOnload = false, + sawOnerror = false, + onloadData = null; + GM_download({ + url, + name, + downloadMode: "browser", + onprogress: (p) => updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1), + onload: (d) => { + sawOnload = true; + onloadData = d; + logLine(`→ event: onload ${JSON.stringify(d)}`); + }, + onerror: (e) => { + sawOnerror = true; + logLine(`→ event: onerror ${JSON.stringify(e)}`); + }, + }); + showProgress("downloading 100 MB (cancel from chrome://downloads)"); + const v = await awaitVerdict( + "Cancel the download from chrome://downloads, then Mark Pass if you saw onload (and no onerror).", + 300 + ); + hideProgress(); + const ctx = `sawOnload=${sawOnload}, sawOnerror=${sawOnerror}, onloadData=${JSON.stringify(onloadData)}`; + if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (${ctx})`); + if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (${ctx})`); + // The exact regression this guards: onerror on user-cancel is the bug evaluated above. + if (sawOnerror) throw new Error(`onerror fired on user-cancel — that's the save_cancelled regression (${ctx})`); + if (!sawOnload) + logLine( + `note: neither onload nor onerror fired — did you actually cancel? Marking Pass anyway because user said so.` + ); + } + ); manualTest("Verify last download wrote a real file (visual check)", async () => { const name = nameFor("manual-visual-check", "txt"); @@ -1033,34 +1343,51 @@ const enableTool = true; GM_download({ url: blobUrl, name, - onload: () => { landed = true; logLine("→ event: onload — file should be on disk now."); }, - onerror: (e) => { logLine(`→ event: onerror ${JSON.stringify(e)}`); }, + onload: () => { + landed = true; + logLine("→ event: onload — file should be on disk now."); + }, + onerror: (e) => { + logLine(`→ event: onerror ${JSON.stringify(e)}`); + }, }); const v = await awaitVerdict(`Open ${escapeHtml(name)} and confirm the contents match.`, 240); URL.revokeObjectURL(blobUrl); if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (landed=${landed})`); if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (landed=${landed})`); - if (!landed) logLine(`note: marked Pass but onload didn't fire — file presence is the source of truth here`); + if (!landed) + logLine( + `note: marked Pass but onload didn't fire — file presence is the source of truth here` + ); }); // ---------- Runner ---------- - async function runOne(t, idx, total) { + async function runOne(t, idx, total, session) { setStatus(`running (${idx + 1}/${total}): ${t.name}`); if (!t.manual) showProgress(t.name); const title = `• ${t.name}`; + const result = t.manual + ? session.manual("GM_download", t.name, "用户完成操作并作出裁决", "待人工确认", "操作说明见专用面板") + : null; const t0 = performance.now(); try { logLine(`▶️ ${escapeHtml(t.name)}`); await t.run(); - pass(`${title} (${fmtMs(performance.now() - t0)})`); + const detail = `${title} (${fmtMs(performance.now() - t0)})`; + pass(detail); + reportVerdict(session, result, "PASS", detail, t.name); } catch (e) { const extra = e && e.stack ? e.stack : String(e); - const msg = String(e && e.message || e); + const msg = String((e && e.message) || e); if (msg.startsWith("SKIP:")) { // Soft outcome — count as skip, not as fail. - skip(`${title} (${fmtMs(performance.now() - t0)}) — ${msg.slice(5).trim()}`); + const detail = `${title} (${fmtMs(performance.now() - t0)}) — ${msg.slice(5).trim()}`; + skip(detail); + reportVerdict(session, result, "SKIP", detail, t.name); } else { - fail(`${title} (${fmtMs(performance.now() - t0)})`, extra); + const detail = `${title} (${fmtMs(performance.now() - t0)})`; + fail(detail, extra); + reportVerdict(session, result, "FAIL", `${detail}: ${msg}`, t.name); } } finally { hideProgress(); @@ -1072,25 +1399,38 @@ const enableTool = true; let running = false; function setAllButtonsDisabled(disabled) { panel.querySelector("#start").disabled = disabled; - $manualButtons.querySelectorAll("button").forEach((b) => { b.disabled = disabled; b.style.opacity = disabled ? "0.5" : "1"; }); + $manualButtons.querySelectorAll("button").forEach((b) => { + b.disabled = disabled; + b.style.opacity = disabled ? "0.5" : "1"; + }); } async function runAuto() { - if (running) { logLine("Already running — wait for the current suite to finish."); return; } + if (running) { + logLine("Already running — wait for the current suite to finish."); + return; + } running = true; setAllButtonsDisabled(true); try { const auto = tests.filter((t) => !t.manual); + reportSession = newReportSession(); + reportSession.start(); const names = auto.map((t) => t.name); setQueue(names.slice()); logLine(`Starting GM_download auto suite — ${new Date().toLocaleString()} — runTag=${RUN_TAG}`); - logLine(`Files will appear under ${escapeHtml(getPrefix())} with prefix ${escapeHtml(RUN_TAG)}-`); + logLine( + `Files will appear under ${escapeHtml(getPrefix())} with prefix ${escapeHtml(RUN_TAG)}-` + ); for (let i = 0; i < auto.length; i++) { - await runOne(auto[i], i, auto.length); + await runOne(auto[i], i, auto.length, reportSession); setQueue(names.slice(i + 1)); } setStatus("done"); - logLine(`Done. Summary — ✅ ${state.pass} ❌ ${state.fail} ⏭️ ${state.skip}`); + logLine( + `Done. Summary — PASS ${state.pass} FAIL ${state.fail} WARN ${state.warn} INFO ${state.info} SKIP ${state.skip}` + ); + reportSession.finish(); } finally { running = false; setAllButtonsDisabled(false); @@ -1098,27 +1438,44 @@ const enableTool = true; } // Build manual buttons. - tests.filter((t) => t.manual).forEach((t) => { - const b = h("button", - { style: btnStyle("#7c3aed"), - onclick: async () => { - if (running) { logLine("Another test is already running — wait for it to finish."); return; } - running = true; - setAllButtonsDisabled(true); - try { await runOne(t, 0, 1); } - finally { - running = false; - setAllButtonsDisabled(false); - setStatus("idle"); - } - } }, - t.name); - $manualButtons.appendChild(b); - }); + tests + .filter((t) => t.manual) + .forEach((t) => { + const b = h( + "button", + { + style: btnStyle("#7c3aed"), + onclick: async () => { + if (running) { + logLine("Another test is already running — wait for it to finish."); + return; + } + running = true; + setAllButtonsDisabled(true); + reportSession = newReportSession(); + reportSession.start(); + try { + await runOne(t, 0, 1, reportSession); + reportSession.finish(); + } finally { + running = false; + setAllButtonsDisabled(false); + setStatus("idle"); + } + }, + }, + t.name + ); + $manualButtons.appendChild(b); + }); // ---------- Boot ---------- - logLine(`GM_download Test Harness ready. Click Run Auto to run the auto suite, or open Manual tests for human-in-the-loop cases.`); - logLine(`Manual tests use a verdict bar: read the instructions, do the action, then click Mark Pass / Mark Fail / Skip. A countdown auto-skips if you walk away.`); + logLine( + `GM_download Test Harness ready. Click Run Auto to run the auto suite, or open Manual tests for human-in-the-loop cases.` + ); + logLine( + `Manual tests use a verdict bar: read the instructions, do the action, then click Mark Pass / Mark Fail / Skip. A countdown auto-skips if you walk away.` + ); logLine(`Tip: change the prefix above if you want files in a different sub-folder.`); setStatus("idle"); diff --git a/example/tests/gm_menu_test.js b/example/tests/gm_menu_test.js index 216015dfb..c2c3ded5d 100644 --- a/example/tests/gm_menu_test.js +++ b/example/tests/gm_menu_test.js @@ -6,41 +6,67 @@ // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js // ==/UserScript== (async function () { - 'use strict'; + "use strict"; const checkSubFrameIdSequence = false; const intervalChanging = false; const skipClickCheck = false; + const report = SCTest.createReportSession({ name: "GM_registerMenuCommand", reporter: "console" }); + report.start(); + const reportInfo = (name, actual, detail) => report.note("菜单观察", name, "观察记录", actual, detail); - let myResolve = () => { }; + let myResolve = () => {}; const waitNext = async () => { await new Promise((resolve) => { - myResolve = () => { setTimeout(resolve, 50) }; + myResolve = () => { + setTimeout(resolve, 50); + }; }); }; const waitActions = async (...messages) => { - if (skipClickCheck) return; + if (skipClickCheck) { + report.note( + "菜单操作", + messages.join(" / "), + "人工操作已启用", + "已跳过", + "skipClickCheck=true,未等待菜单回调。" + ); + return; + } messages = messages.flat(); for (const message of messages) { + const pending = report.manual( + "菜单操作", + message, + "收到对应菜单回调", + "待人工点击", + "请按提示打开扩展菜单并点击对应项目。" + ); console.log(message); await waitNext(); + report.update(pending, "PASS", { + actual: "收到菜单回调", + detail: "人工点击触发了等待中的回调。", + manualVerdict: "PASS", + }); } }; const isInSubFrame = () => { - try { return window.top !== window; } catch { return true; } - } + }; if (intervalChanging) { // TM: 在打开菜单时,显示会不断改变 @@ -49,8 +75,16 @@ setInterval(() => { if (p) GM_unregisterMenuCommand(p); i++; - GM_registerMenuCommand(`interval-m-${i}`, () => { console.log(`${i}`); }, {id: "m"}); - p = GM_registerMenuCommand(`interval-n-${i}`, () => { console.log(`${i}`); }); + GM_registerMenuCommand( + `interval-m-${i}`, + () => { + console.log(`${i}`); + }, + { id: "m" } + ); + p = GM_registerMenuCommand(`interval-n-${i}`, () => { + console.log(`${i}`); + }); }, 1000); // return; } @@ -59,98 +93,145 @@ let arr = []; arr.push( - GM_registerMenuCommand("test", () => { console.log(`${key}-1`); }), - GM_registerMenuCommand("test", () => { console.log(`${key}-2`); }), - GM_registerMenuCommand("test", () => { console.log(`${key}-3`); }) + GM_registerMenuCommand("test", () => { + console.log(`${key}-1`); + }), + GM_registerMenuCommand("test", () => { + console.log(`${key}-2`); + }), + GM_registerMenuCommand("test", () => { + console.log(`${key}-3`); + }) ); if (isInSubFrame()) { - arr.push(GM_registerMenuCommand("test-sub", () => { console.log(`${key}-sub`); })); + arr.push( + GM_registerMenuCommand("test-sub", () => { + console.log(`${key}-sub`); + }) + ); } else { - arr.push(GM_registerMenuCommand("test-main", () => { console.log(`${key}-main`); })); + arr.push( + GM_registerMenuCommand("test-main", () => { + console.log(`${key}-main`); + }) + ); } - arr.push(GM_registerMenuCommand(`test-${location.origin}`, () => { console.log(`${key}-origin`); })); - console.log(`checkSubFrameIdSequence (key=${key}, frame=${isInSubFrame()})`, arr.join("...")); + arr.push( + GM_registerMenuCommand(`test-${location.origin}`, () => { + console.log(`${key}-origin`); + }) + ); + const sequence = `key=${key}, frame=${isInSubFrame()}, ids=${arr.join("...")}`; + console.log("checkSubFrameIdSequence", sequence); + reportInfo("子 frame 菜单 ID 序列", sequence, "仅记录实际注册结果,不把不同浏览器的 ID 规则自动判为通过。"); // return; } let obj1 = { id: "abc" }; - const r01 = GM_registerMenuCommand("MenuReg abc-1", () => { - - console.log("abc-1"); - myResolve(); - }, obj1); - - const r02 = GM_registerMenuCommand("MenuReg abc-2", () => { - - console.log("abc-2"); - myResolve(); - }, obj1); - + const r01 = GM_registerMenuCommand( + "MenuReg abc-1", + () => { + console.log("abc-1"); + myResolve(); + }, + obj1 + ); + + const r02 = GM_registerMenuCommand( + "MenuReg abc-2", + () => { + console.log("abc-2"); + myResolve(); + }, + obj1 + ); + + reportInfo("同 ID 注册结果", `r01=${String(r01)}, r02=${String(r02)}`, "菜单注册返回值的兼容性观察。"); console.log("abc-1 id === abc", r01 === "abc"); console.log("abc-2 id === abc", r02 === "abc"); // there shall be only "MenuReg abc-2" in the menu. await waitActions("There shall be only 'MenuReg abc-2'. Click it to continue."); - GM_registerMenuCommand("MenuReg abc-1", () => { - - console.log("abc-1.abd"); - myResolve(); - }, { id: "abd" }); - - GM_registerMenuCommand("MenuReg abc-2", () => { - - console.log("abc-2.abe"); - myResolve(); - }, { id: "abe" }); - + GM_registerMenuCommand( + "MenuReg abc-1", + () => { + console.log("abc-1.abd"); + myResolve(); + }, + { id: "abd" } + ); + + GM_registerMenuCommand( + "MenuReg abc-2", + () => { + console.log("abc-2.abe"); + myResolve(); + }, + { id: "abe" } + ); // there shall be only "MenuReg abc-1" and "MenuReg abc-2" in the menu. await waitActions("There shall be 'MenuReg abc-2' and 'MenuReg abc-1'. Click either them to continue."); - - GM_registerMenuCommand("MenuReg abc-2", () => { - - console.log("abc-2.abf"); - myResolve(); - }, { id: "abf", accessKey: "h" }); + GM_registerMenuCommand( + "MenuReg abc-2", + () => { + console.log("abc-2.abf"); + myResolve(); + }, + { id: "abf", accessKey: "h" } + ); // there shall be only "MenuReg abc-1" and "MenuReg abc-2" in the menu. - await waitActions("There shall be 'MenuReg abc-2', 'MenuReg abc-1' and 'MenuReg abc-2 (H)'. Click either them to continue."); + await waitActions( + "There shall be 'MenuReg abc-2', 'MenuReg abc-1' and 'MenuReg abc-2 (H)'. Click either them to continue." + ); GM_unregisterMenuCommand("abc"); GM_unregisterMenuCommand("abd"); GM_unregisterMenuCommand("abe"); GM_unregisterMenuCommand("abf"); - - - const p10 = GM_registerMenuCommand("MenuReg D-23", () => { - - console.log(110); - myResolve(); - }, "b"); - - - const p20 = GM_registerMenuCommand("MenuReg D-23", () => { - - console.log(120); - myResolve(); - }, "b"); - + const p10 = GM_registerMenuCommand( + "MenuReg D-23", + () => { + console.log(110); + myResolve(); + }, + "b" + ); + + const p20 = GM_registerMenuCommand( + "MenuReg D-23", + () => { + console.log(120); + myResolve(); + }, + "b" + ); + + reportInfo( + "字符串 accessKey 注册结果", + `p10=${String(p10)}, p20=${String(p20)}`, + "记录管理器返回值,具体序列需人工结合菜单观察。" + ); console.log("p10 === 1", p10 === 1); console.log("p20 === 2", p20 === 2); // MenuReg D-23 clicking shall give both 110 and 120 await waitActions("Click [MenuReg D-23] -> 110, 120"); - - const p30 = GM_registerMenuCommand("MenuReg D-26", () => { - - console.log(130); - myResolve(); - }, { id: "2" }); + const p30 = GM_registerMenuCommand( + "MenuReg D-26", + () => { + console.log(130); + myResolve(); + }, + { id: "2" } + ); + reportInfo("对象 ID 注册结果", `p30=${String(p30)}`, "记录显式字符串 ID 的实际返回值。"); console.log("p30 === '2'", p30 === "2"); // MenuReg D-23 clicking shall give 110 @@ -158,12 +239,15 @@ await waitActions("Click [MenuReg D-23] -> 110", "Click [MenuReg D-26] -> 130"); - - const p32 = GM_registerMenuCommand("MenuReg D-26", () => { - - console.log(210); - myResolve(); - }, { id: 2 }); + const p32 = GM_registerMenuCommand( + "MenuReg D-26", + () => { + console.log(210); + myResolve(); + }, + { id: 2 } + ); + reportInfo("数字 ID 注册结果", `p32=${String(p32)}`, "记录数字 ID 的实际返回值。"); console.log("p32 === 2", p32 === 2); // MenuReg D-23 clicking shall give 110 @@ -171,12 +255,15 @@ await waitActions("Click [MenuReg D-23] -> 110", "Click [MenuReg D-26] -> 210"); - - const p33 = GM_registerMenuCommand("MenuReg D-26", () => { - - console.log(220); - myResolve(); - }, { id: 3 }); + const p33 = GM_registerMenuCommand( + "MenuReg D-26", + () => { + console.log(220); + myResolve(); + }, + { id: 3 } + ); + reportInfo("递增 ID 注册结果", `p33=${String(p33)}`, "记录重复注册后的实际返回值。"); console.log("p33 === 3", p33 === 3); // MenuReg D-23 clicking shall give 110 @@ -184,13 +271,15 @@ await waitActions("Click [MenuReg D-23] -> 110", "Click [MenuReg D-26] -> 210, 220"); - - - const p34 = GM_registerMenuCommand("MenuReg D-26", () => { - - console.log(230); - myResolve(); - }, { id: "4" }); + const p34 = GM_registerMenuCommand( + "MenuReg D-26", + () => { + console.log(230); + myResolve(); + }, + { id: "4" } + ); + reportInfo("删除后 ID 注册结果", `p34=${String(p34)}`, "记录注销菜单后的实际返回值。"); console.log("p34 === '4'", p34 === "4"); // MenuReg D-23 clicking shall give 110 @@ -199,27 +288,20 @@ GM_unregisterMenuCommand("4"); - // MenuReg D-23 clicking shall give 110 // MenuReg D-26 clicking shall give 210 220 await waitActions("Click [MenuReg D-23] -> 110", "Click [MenuReg D-26] -> 210, 220"); - - const p40 = GM_registerMenuCommand("MenuReg D-40", () => { - console.log(601); }); const p50 = GM_registerMenuCommand("MenuReg D-50", () => { - console.log(602); }); + reportInfo("末尾菜单注册结果", `p40=${String(p40)}, p50=${String(p50)}`, "需要人工打开菜单确认最终项目与顺序。"); console.log("p40, p50", [p40, p50]); // TM gives 3&4 - - - })().finally(() => { console.log("finish"); + report.finish(); }); - diff --git a/example/tests/gm_value_test.js b/example/tests/gm_value_test.js index d0fee5b33..aa7caeaa5 100644 --- a/example/tests/gm_value_test.js +++ b/example/tests/gm_value_test.js @@ -9,97 +9,100 @@ // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js // @run-at document-idle // ==/UserScript== (function () { - 'use strict'; + "use strict"; - if (!location.search.includes('testGMAddValueChangeListener')) return; + if (!location.search.includes("testGMAddValueChangeListener")) return; - document.documentElement.appendChild(document.createElement("style")).textContent=`@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700,800&display=swap');`; + document.documentElement.appendChild(document.createElement("style")).textContent = + `@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700,800&display=swap');`; /* ══════════════════════════════════════════════════════════ SHARED CONSTANTS ══════════════════════════════════════════════════════════ */ - const FRAME_IDS = ['main', 'iframe1', 'iframe2', 'iframe3']; + const FRAME_IDS = ["main", "iframe1", "iframe2", "iframe3"]; const WRITE_KEY = { - main: 'key_from_main', - iframe1: 'key_from_iframe1', - iframe2: 'key_from_iframe2', - iframe3: 'key_from_iframe3', + main: "key_from_main", + iframe1: "key_from_iframe1", + iframe2: "key_from_iframe2", + iframe3: "key_from_iframe3", }; const ALL_KEYS = Object.values(WRITE_KEY); const ACCENT = { - main: '#0369a1', - iframe1: '#b91c1c', - iframe2: '#15803d', - iframe3: '#a16207', + main: "#0369a1", + iframe1: "#b91c1c", + iframe2: "#15803d", + iframe3: "#a16207", }; const LABEL = { - main: '🖥 Main Frame', - iframe1: '📦 iFrame #1', - iframe2: '📦 iFrame #2', - iframe3: '📦 iFrame #3', + main: "🖥 Main Frame", + iframe1: "📦 iFrame #1", + iframe2: "📦 iFrame #2", + iframe3: "📦 iFrame #3", }; /* ══════════════════════════════════════════════════════════ CONTEXT DETECTION ══════════════════════════════════════════════════════════ */ const isMain = window.self === window.top; - const frameId = new URLSearchParams(location.search).get('frameId') - || (isMain ? 'main' : 'unknown'); + const frameId = new URLSearchParams(location.search).get("frameId") || (isMain ? "main" : "unknown"); /* ══════════════════════════════════════════════════════════ HELPERS ══════════════════════════════════════════════════════════ */ function escHtml(s) { - return String(s).replace(/[&<>"']/g, ch => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - }[ch])); + return String(s).replace( + /[&<>"']/g, + (ch) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[ch] + ); } function fmtVal(v) { - return v === undefined - ? 'not set' - : escHtml(JSON.stringify(v)); + return v === undefined ? 'not set' : escHtml(JSON.stringify(v)); } function nowTime() { - return new Date().toLocaleTimeString('en-GB', { hour12: false }); + return new Date().toLocaleTimeString("en-GB", { hour12: false }); } /* ══════════════════════════════════════════════════════════ MSG BUS ══════════════════════════════════════════════════════════ */ - const MSG_NS = 'GMTEST_'; + const MSG_NS = "GMTEST_"; const TARGET_ORIGIN = location.origin; function reportLog(entry) { - top.postMessage({ t: MSG_NS + 'LOG', frameId, entry }, TARGET_ORIGIN); + top.postMessage({ t: MSG_NS + "LOG", frameId, entry }, TARGET_ORIGIN); } function reportKV(kvMap) { - top.postMessage({ t: MSG_NS + 'KV', frameId, kvMap }, TARGET_ORIGIN); + top.postMessage({ t: MSG_NS + "KV", frameId, kvMap }, TARGET_ORIGIN); } function reportReady() { - top.postMessage({ t: MSG_NS + 'READY', frameId }, TARGET_ORIGIN); + top.postMessage({ t: MSG_NS + "READY", frameId }, TARGET_ORIGIN); } function reportListeners(ids) { - top.postMessage({ t: MSG_NS + 'LISTENERS', frameId, ids }, TARGET_ORIGIN); + top.postMessage({ t: MSG_NS + "LISTENERS", frameId, ids }, TARGET_ORIGIN); } function sendCmd(win, cmd, data = {}) { - win.postMessage({ t: MSG_NS + 'CMD', cmd, ...data }, TARGET_ORIGIN); + win.postMessage({ t: MSG_NS + "CMD", cmd, ...data }, TARGET_ORIGIN); } /* ══════════════════════════════════════════════════════════ @@ -119,25 +122,25 @@ pushKV(); reportReady(); - window.addEventListener('message', async (e) => { + window.addEventListener("message", async (e) => { if (e.origin !== location.origin) return; if (!e.data || !e.data.t) return; - if (e.data.t !== MSG_NS + 'CMD') return; + if (e.data.t !== MSG_NS + "CMD") return; const { cmd, value } = e.data; - if (cmd === 'SET_STRING') await doSet(`hello_${Date.now()}`); - if (cmd === 'SET_NUMBER') await doSet(Math.floor(Math.random() * 99999)); - if (cmd === 'SET_OBJECT') await doSet({ ts: Date.now(), from: frameId }); - if (cmd === 'SET_NULL') await doSet(null); - if (cmd === 'SET_CUSTOM') await doSet(value); - if (cmd === 'DELETE') await doDel(); + if (cmd === "SET_STRING") await doSet(`hello_${Date.now()}`); + if (cmd === "SET_NUMBER") await doSet(Math.floor(Math.random() * 99999)); + if (cmd === "SET_OBJECT") await doSet({ ts: Date.now(), from: frameId }); + if (cmd === "SET_NULL") await doSet(null); + if (cmd === "SET_CUSTOM") await doSet(value); + if (cmd === "DELETE") await doDel(); - if (cmd === 'REMOVE_LISTENERS') { + if (cmd === "REMOVE_LISTENERS") { removeAllListeners(); } - if (cmd === 'REREGISTER_LISTENERS') { + if (cmd === "REREGISTER_LISTENERS") { removeAllListeners(); registerAllListeners(); } @@ -145,13 +148,13 @@ async function doSet(v) { await GM_setValue(myKey, v); - iLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, 'info'); + iLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, "info"); await pushKV(); } async function doDel() { await GM_deleteValue(myKey); - iLog(`🗑 Deleted ${escHtml(myKey)}`, 'warn'); + iLog(`🗑 Deleted ${escHtml(myKey)}`, "warn"); await pushKV(); } @@ -160,18 +163,18 @@ if (listenerIds[key] != null) continue; const id = GM_addValueChangeListener(key, async (name, oldVal, newVal, remote) => { - const tag = remote ? '🌐 remote' : '📍 local'; + const tag = remote ? "🌐 remote" : "📍 local"; iLog( `${tag} ${escHtml(name)}: ${escHtml(JSON.stringify(oldVal))} → ${escHtml(JSON.stringify(newVal))}`, - remote ? 'good' : 'warn' + remote ? "good" : "warn" ); await pushKV(); }); listenerIds[key] = id; - iLog(`👂 Listener on ${escHtml(key)} (id=${escHtml(id)})`, 'info'); + iLog(`👂 Listener on ${escHtml(key)} (id=${escHtml(id)})`, "info"); } reportListeners(Object.entries(listenerIds).map(([k, id]) => ({ key: k, id }))); @@ -181,11 +184,11 @@ for (const [key, id] of Object.entries(listenerIds)) { try { GM_removeValueChangeListener(id); - } catch (_) { } + } catch (_) {} delete listenerIds[key]; } - iLog('🔇 All listeners removed', 'warn'); + iLog("🔇 All listeners removed", "warn"); reportListeners([]); } @@ -197,16 +200,16 @@ reportKV(kvMap); } - function iLog(msg, type = '') { + function iLog(msg, type = "") { reportLog({ msg, type, t: nowTime() }); if (!iframeShadow) return; - const logBox = iframeShadow.getElementById('iframe-log'); + const logBox = iframeShadow.getElementById("iframe-log"); if (!logBox) return; - const line = document.createElement('div'); - line.className = 'log-line'; + const line = document.createElement("div"); + line.className = "log-line"; line.innerHTML = ` ${escHtml(nowTime())} ${msg} @@ -217,7 +220,7 @@ } function buildIframeBody() { - const accent = ACCENT[frameId] || '#334155'; + const accent = ACCENT[frameId] || "#334155"; document.documentElement.style.cssText = ` margin:0; @@ -236,9 +239,9 @@ overflow:hidden; `; - document.body.textContent = ''; + document.body.textContent = ""; - const host = document.createElement('div'); + const host = document.createElement("div"); host.style.cssText = ` all:initial; display:block; @@ -247,7 +250,7 @@ `; document.body.appendChild(host); - iframeShadow = host.attachShadow({ mode: 'open' }); + iframeShadow = host.attachShadow({ mode: "open" }); const sty = new CSSStyleSheet(); sty.replaceSync(` @@ -361,9 +364,8 @@ // スタイルシートを適用 iframeShadow.adoptedStyleSheets = [sty]; - - const shell = document.createElement('div'); - shell.id = 'iframe-shell'; + const shell = document.createElement("div"); + shell.id = "iframe-shell"; shell.innerHTML = `
${escHtml(LABEL[frameId])}
Controlled by main frame dashboard ↑
@@ -379,13 +381,23 @@ /* ══════════════════════════════════════════════════════════ MAIN FRAME LOGIC ══════════════════════════════════════════════════════════ */ + const report = SCTest.createReportSession({ name: "GM_addValueChangeListener dashboard", reporter: "console" }); + report.start(); + report.note( + "启动观察", + "跨 iframe dashboard", + "页面与 3 个真实 iframe 均可见", + "已创建交互界面", + "这是操作型演示;具体同步结果请结合 dashboard 的日志与值表人工确认。" + ); + const state = { kv: {}, logs: {}, listenerSummary: {}, }; - FRAME_IDS.forEach(id => { + FRAME_IDS.forEach((id) => { state.kv[id] = {}; state.logs[id] = []; state.listenerSummary[id] = []; @@ -394,20 +406,20 @@ const iframeWindows = {}; /* ── Shadow DOM setup ─────────────────────────────────── */ - const host = document.createElement('div'); + const host = document.createElement("div"); host.style.cssText = [ - 'all:initial', - 'position:fixed', - 'inset:0', - 'width:100vw', - 'height:100vh', - 'z-index:2147483647', - 'pointer-events:none', - ].join(';'); + "all:initial", + "position:fixed", + "inset:0", + "width:100vw", + "height:100vh", + "z-index:2147483647", + "pointer-events:none", + ].join(";"); document.body.appendChild(host); - const shadow = host.attachShadow({ mode: 'open' }); + const shadow = host.attachShadow({ mode: "open" }); /* ── Styles ─────────────────────────────────────────────── */ const sty = new CSSStyleSheet(); @@ -709,77 +721,78 @@ shadow.adoptedStyleSheets = [sty]; /* ── Shell ─────────────────────────────────────────────── */ - const shell = document.createElement('div'); - shell.id = 'shell'; + const shell = document.createElement("div"); + shell.id = "shell"; shadow.appendChild(shell); /* ── Left dashboard ─────────────────────────────────────── */ - const dashboard = document.createElement('div'); - dashboard.id = 'dashboard'; + const dashboard = document.createElement("div"); + dashboard.id = "dashboard"; shell.appendChild(dashboard); - const topbar = document.createElement('div'); - topbar.id = 'topbar'; + const topbar = document.createElement("div"); + topbar.id = "topbar"; topbar.innerHTML = `⚙ GM_addValueChangeListener Test`; - const closeBtn = document.createElement('button'); - closeBtn.className = 'danger'; - closeBtn.textContent = '✕ close'; - closeBtn.onclick = () => host.remove(); + const closeBtn = document.createElement("button"); + closeBtn.className = "danger"; + closeBtn.textContent = "✕ close"; + closeBtn.onclick = () => { + report.finish(); + host.remove(); + }; topbar.appendChild(closeBtn); dashboard.appendChild(topbar); /* ── Panel cards ───────────────────────────────────────── */ const panelRefs = {}; - FRAME_IDS.forEach(id => { + FRAME_IDS.forEach((id) => { const accent = ACCENT[id]; - const card = document.createElement('div'); - card.className = 'p-card'; - card.style.borderColor = accent + '55'; + const card = document.createElement("div"); + card.className = "p-card"; + card.style.borderColor = accent + "55"; - const title = document.createElement('div'); - title.className = 'p-title'; + const title = document.createElement("div"); + title.className = "p-title"; title.style.color = accent; title.textContent = LABEL[id]; card.appendChild(title); - const subtitle = document.createElement('div'); - subtitle.className = 'p-subtitle'; - subtitle.textContent = id === 'main' - ? 'runs in this window' - : 'runs in real iframe on the right →'; + const subtitle = document.createElement("div"); + subtitle.className = "p-subtitle"; + subtitle.textContent = id === "main" ? "runs in this window" : "runs in real iframe on the right →"; card.appendChild(subtitle); - const wLabel = document.createElement('div'); - wLabel.className = 'sec-label'; - wLabel.textContent = 'Write value'; + const wLabel = document.createElement("div"); + wLabel.className = "sec-label"; + wLabel.textContent = "Write value"; card.appendChild(wLabel); - const writeRow = document.createElement('div'); - writeRow.className = 'btn-row'; + const writeRow = document.createElement("div"); + writeRow.className = "btn-row"; function makeBtn(text, danger) { - const b = document.createElement('button'); + const b = document.createElement("button"); b.textContent = text; if (danger) { - b.className = 'danger'; + b.className = "danger"; } else { b.style.color = accent; - b.style.borderColor = accent + '66'; - b.style.background = '#ffffff'; + b.style.borderColor = accent + "66"; + b.style.background = "#ffffff"; } return b; } const cmdMap = [ - ['string', 'SET_STRING'], - ['number', 'SET_NUMBER'], - ['object', 'SET_OBJECT'], - ['null', 'SET_NULL'], + ["string", "SET_STRING"], + ["number", "SET_NUMBER"], + ["object", "SET_OBJECT"], + ["null", "SET_NULL"], ]; cmdMap.forEach(([label2, cmd]) => { @@ -788,58 +801,58 @@ writeRow.appendChild(b); }); - const delB = makeBtn('delete', true); - delB.onclick = () => dispatchCmd(id, 'DELETE'); + const delB = makeBtn("delete", true); + delB.onclick = () => dispatchCmd(id, "DELETE"); writeRow.appendChild(delB); card.appendChild(writeRow); - const hr1 = document.createElement('div'); - hr1.className = 'hr'; + const hr1 = document.createElement("div"); + hr1.className = "hr"; card.appendChild(hr1); - const kvLabel = document.createElement('div'); - kvLabel.className = 'sec-label'; - kvLabel.textContent = 'GM Values'; + const kvLabel = document.createElement("div"); + kvLabel.className = "sec-label"; + kvLabel.textContent = "GM Values"; card.appendChild(kvLabel); - const kvTable = document.createElement('div'); - kvTable.className = 'kv-table'; + const kvTable = document.createElement("div"); + kvTable.className = "kv-table"; card.appendChild(kvTable); - const hr2 = document.createElement('div'); - hr2.className = 'hr'; + const hr2 = document.createElement("div"); + hr2.className = "hr"; card.appendChild(hr2); - const lcLabel = document.createElement('div'); - lcLabel.className = 'sec-label'; - lcLabel.textContent = 'Listener control'; + const lcLabel = document.createElement("div"); + lcLabel.className = "sec-label"; + lcLabel.textContent = "Listener control"; card.appendChild(lcLabel); - const lcRow = document.createElement('div'); - lcRow.className = 'btn-row'; + const lcRow = document.createElement("div"); + lcRow.className = "btn-row"; - const rmB = makeBtn('🔇 remove all', true); - rmB.onclick = () => dispatchCmd(id, 'REMOVE_LISTENERS'); + const rmB = makeBtn("🔇 remove all", true); + rmB.onclick = () => dispatchCmd(id, "REMOVE_LISTENERS"); - const reB = makeBtn('🔊 re-register'); - reB.onclick = () => dispatchCmd(id, 'REREGISTER_LISTENERS'); + const reB = makeBtn("🔊 re-register"); + reB.onclick = () => dispatchCmd(id, "REREGISTER_LISTENERS"); lcRow.appendChild(rmB); lcRow.appendChild(reB); - const dotWrap = document.createElement('div'); - dotWrap.style.cssText = 'display:flex;gap:6px;align-items:center;margin-top:7px;flex-wrap:wrap;'; + const dotWrap = document.createElement("div"); + dotWrap.style.cssText = "display:flex;gap:6px;align-items:center;margin-top:7px;flex-wrap:wrap;"; const dotMap = {}; - ALL_KEYS.forEach(k => { - const dot = document.createElement('span'); - dot.className = 'dot'; + ALL_KEYS.forEach((k) => { + const dot = document.createElement("span"); + dot.className = "dot"; dot.title = k; - const lbl2 = document.createElement('span'); - lbl2.style.cssText = 'font-size:10px;color:#475569;'; - lbl2.textContent = k.replace('key_from_', ''); + const lbl2 = document.createElement("span"); + lbl2.style.cssText = "font-size:10px;color:#475569;"; + lbl2.textContent = k.replace("key_from_", ""); dotWrap.appendChild(dot); dotWrap.appendChild(lbl2); @@ -850,23 +863,23 @@ card.appendChild(lcRow); card.appendChild(dotWrap); - const hr3 = document.createElement('div'); - hr3.className = 'hr'; + const hr3 = document.createElement("div"); + hr3.className = "hr"; card.appendChild(hr3); - const logLabel = document.createElement('div'); - logLabel.className = 'sec-label'; - logLabel.textContent = 'Event log'; + const logLabel = document.createElement("div"); + logLabel.className = "sec-label"; + logLabel.textContent = "Event log"; card.appendChild(logLabel); - const logBox = document.createElement('div'); - logBox.className = 'log-box'; + const logBox = document.createElement("div"); + logBox.className = "log-box"; card.appendChild(logBox); - const clrB = makeBtn('✕ clear log', true); - clrB.style.marginTop = '5px'; + const clrB = makeBtn("✕ clear log", true); + clrB.style.marginTop = "5px"; clrB.onclick = () => { - logBox.innerHTML = ''; + logBox.innerHTML = ""; state.logs[id] = []; }; card.appendChild(clrB); @@ -883,17 +896,17 @@ }); /* ── Right iframe strip ─────────────────────────────────── */ - const strip = document.createElement('div'); - strip.id = 'iframe-strip'; + const strip = document.createElement("div"); + strip.id = "iframe-strip"; shell.appendChild(strip); - const BASE_URL = location.href.split('?')[0]; + const BASE_URL = location.href.split("?")[0]; - ['iframe1', 'iframe2', 'iframe3'].forEach(fid => { - const wrap = document.createElement('div'); - wrap.className = 'iframe-wrap'; + ["iframe1", "iframe2", "iframe3"].forEach((fid) => { + const wrap = document.createElement("div"); + wrap.className = "iframe-wrap"; - const iframe = document.createElement('iframe'); + const iframe = document.createElement("iframe"); iframe.src = `${BASE_URL}?testGMAddValueChangeListener&frameId=${encodeURIComponent(fid)}`; iframe.title = fid; @@ -914,11 +927,18 @@ const listenerIds = {}; - function mLog(msg, type = '') { + function mLog(msg, type = "") { const { logBox } = refs(); - - const line = document.createElement('div'); - line.className = 'log-line'; + report.note( + "主 frame 观察", + msg.replace(/<[^>]+>/g, ""), + "收到 GM 值/监听器事件", + type || "info", + "dashboard 的可视日志是此演示的主要观察面。" + ); + + const line = document.createElement("div"); + line.className = "log-line"; line.innerHTML = ` ${nowTime()} ${msg} @@ -931,20 +951,20 @@ async function mRefreshKV() { const { kvTable, myKey: mk, accent } = refs(); - kvTable.innerHTML = ''; + kvTable.innerHTML = ""; for (const k of ALL_KEYS) { const v = await GM_getValue(k, undefined); // main frame const own = k === mk; - const card = document.createElement('div'); - card.className = 'kv-card'; + const card = document.createElement("div"); + card.className = "kv-card"; - if (own) card.style.borderColor = accent + '88'; + if (own) card.style.borderColor = accent + "88"; card.innerHTML = ` -
${escHtml(k)}${own ? ' (mine)' : ''}
-
+
${escHtml(k)}${own ? " (mine)" : ""}
+
${fmtVal(v)}
`; @@ -958,77 +978,83 @@ if (listenerIds[key] != null) continue; const id = GM_addValueChangeListener(key, async (name, oldVal, newVal, remote) => { - const tag = remote ? '🌐 remote' : '📍 local'; + const tag = remote ? "🌐 remote" : "📍 local"; mLog( `${tag} ${escHtml(name)}: ${escHtml(JSON.stringify(oldVal))} → ${escHtml(JSON.stringify(newVal))}`, - remote ? 'good' : 'warn' + remote ? "good" : "warn" ); await mRefreshKV(); - updateDots('main', Object.entries(listenerIds).map(([k, i]) => ({ key: k, id: i }))); + updateDots( + "main", + Object.entries(listenerIds).map(([k, i]) => ({ key: k, id: i })) + ); }); listenerIds[key] = id; mLog( - `${isReregister ? '👂 Re-registered' : '👂 Listener on'} ${escHtml(key)} (id=${escHtml(id)})`, - 'info' + `${isReregister ? "👂 Re-registered" : "👂 Listener on"} ${escHtml(key)} (id=${escHtml(id)})`, + "info" ); } - updateDots('main', Object.entries(listenerIds).map(([k, i]) => ({ key: k, id: i }))); + updateDots( + "main", + Object.entries(listenerIds).map(([k, i]) => ({ key: k, id: i })) + ); } function removeMainListeners() { for (const [k, i] of Object.entries(listenerIds)) { try { GM_removeValueChangeListener(i); - } catch (_) { } + } catch (_) {} delete listenerIds[k]; } - mLog('🔇 All listeners removed', 'warn'); - updateDots('main', []); + mLog("🔇 All listeners removed", "warn"); + updateDots("main", []); } registerMainListeners(false); mRefreshKV(); window._gmtest_mainDispatch = async (cmd) => { - if (cmd === 'SET_STRING') { + if (cmd === "SET_STRING") { const v = `hello_${Date.now()}`; await GM_setValue(myKey, v); - mLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, 'info'); + mLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, "info"); } - if (cmd === 'SET_NUMBER') { + if (cmd === "SET_NUMBER") { const v = Math.floor(Math.random() * 99999); await GM_setValue(myKey, v); - mLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, 'info'); + mLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, "info"); } - if (cmd === 'SET_OBJECT') { - const v = { ts: Date.now(), from: 'main' }; + if (cmd === "SET_OBJECT") { + const v = { ts: Date.now(), from: "main" }; await GM_setValue(myKey, v); - mLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, 'info'); + mLog(`✏️ Set ${escHtml(myKey)} = ${escHtml(JSON.stringify(v))}`, "info"); } - if (cmd === 'SET_NULL') { + if (cmd === "SET_NULL") { await GM_setValue(myKey, null); - mLog(`✏️ Set ${escHtml(myKey)} = null`, 'info'); + mLog(`✏️ Set ${escHtml(myKey)} = null`, "info"); } - if (cmd === 'DELETE') { + if (cmd === "DELETE") { await GM_deleteValue(myKey); - mLog(`🗑 Deleted ${escHtml(myKey)}`, 'warn'); + mLog(`🗑 Deleted ${escHtml(myKey)}`, "warn"); } - if (cmd === 'REMOVE_LISTENERS') { + if (cmd === "REMOVE_LISTENERS") { removeMainListeners(); } - if (cmd === 'REREGISTER_LISTENERS') { + if (cmd === "REREGISTER_LISTENERS") { removeMainListeners(); registerMainListeners(true); } @@ -1038,7 +1064,7 @@ })(); /* ── postMessage handler ───────────────────────────────── */ - window.addEventListener('message', (e) => { + window.addEventListener("message", (e) => { if (e.origin !== location.origin) return; if (!e.data || !e.data.t) return; @@ -1046,33 +1072,46 @@ if (!FRAME_IDS.includes(fid)) return; - if (fid !== 'main' && iframeWindows[fid] && e.source !== iframeWindows[fid]) return; + if (fid !== "main" && iframeWindows[fid] && e.source !== iframeWindows[fid]) return; - if (t === MSG_NS + 'LOG') { + if (t === MSG_NS + "LOG") { appendLog(fid, entry); } - if (t === MSG_NS + 'KV') { + if (t === MSG_NS + "KV") { renderKV(fid, kvMap); } - if (t === MSG_NS + 'READY') { + if (t === MSG_NS + "READY") { appendLog(fid, { t: nowTime(), - msg: '🚀 iframe ready', - type: 'info', + msg: "🚀 iframe ready", + type: "info", }); } - if (t === MSG_NS + 'LISTENERS') { + if (t === MSG_NS + "LISTENERS") { updateDots(fid, ids); } }); /* ── dispatchCmd ───────────────────────────────────────── */ function dispatchCmd(targetId, cmd) { - if (targetId === 'main') { - window._gmtest_mainDispatch(cmd); + const pending = report.manual( + "跨 iframe 操作", + `${targetId}: ${cmd}`, + "dashboard 与目标 frame 都显示预期值/事件", + "等待人工观察", + "操作不会自动判定通过;请检查右侧 frame、值表和事件日志。" + ); + if (targetId === "main") { + window._gmtest_mainDispatch(cmd).then(() => { + report.update(pending, "MANUAL", { + actual: "操作已发送并完成本地处理", + detail: "请继续人工检查 dashboard 的跨 frame 变化。", + }); + report.finish(); + }); return; } @@ -1080,12 +1119,18 @@ if (win) { sendCmd(win, cmd, {}); + report.finish(); } else { appendLog(targetId, { t: nowTime(), - msg: '⚠️ iframe not ready yet — try again', - type: 'warn', + msg: "⚠️ iframe not ready yet — try again", + type: "warn", + }); + report.update(pending, "WARN", { + actual: "目标 iframe 尚未就绪", + detail: "操作未发出,请等待 iframe ready 后重试。", }); + report.finish(); } } @@ -1096,15 +1141,22 @@ const { logBox } = refs; - const line = document.createElement('div'); - line.className = 'log-line'; + const line = document.createElement("div"); + line.className = "log-line"; line.innerHTML = ` ${escHtml(entry.t || nowTime())} - ${entry.msg || ''} + ${entry.msg || ""} `; logBox.appendChild(line); logBox.scrollTop = logBox.scrollHeight; + report.note( + "跨 iframe 观察", + `${fid}: ${entry.msg || "(empty)"}`, + "消息被主 frame 接收", + entry.type || "info", + "仅记录消息链路;值是否正确仍需人工确认。" + ); } function renderKV(fid, kvMap) { @@ -1113,20 +1165,20 @@ const { kvTable, myKey, accent } = refs; - kvTable.innerHTML = ''; + kvTable.innerHTML = ""; for (const k of ALL_KEYS) { const v = kvMap[k]; const own = k === myKey; - const card = document.createElement('div'); - card.className = 'kv-card'; + const card = document.createElement("div"); + card.className = "kv-card"; - if (own) card.style.borderColor = accent + '88'; + if (own) card.style.borderColor = accent + "88"; card.innerHTML = ` -
${escHtml(k)}${own ? ' (mine)' : ''}
-
+
${escHtml(k)}${own ? " (mine)" : ""}
+
${fmtVal(v)}
`; @@ -1140,10 +1192,11 @@ if (!refs) return; const { dotMap } = refs; - const activeKeys = new Set((ids || []).map(x => x.key)); + const activeKeys = new Set((ids || []).map((x) => x.key)); for (const [k, dot] of Object.entries(dotMap)) { - dot.className = 'dot' + (activeKeys.has(k) ? ' on' : ''); + dot.className = "dot" + (activeKeys.has(k) ? " on" : ""); } } + report.finish(); })(); diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js index 965bec133..b6351d2e9 100644 --- a/example/tests/gm_xhr_cookie_test.js +++ b/example/tests/gm_xhr_cookie_test.js @@ -13,7 +13,7 @@ (async function () { "use strict"; - const { describe, it, expect, run } = SCTest.create({ name: "GM_xmlhttpRequest cookie 覆盖测试" }); + const { describe, check, expect, run } = SCTest.create({ name: "GM_xmlhttpRequest cookie 覆盖测试" }); const MOCKHTTP = "https://mockhttp.org"; @@ -26,26 +26,12 @@ if (response.status >= 200 && response.status < 300) { resolve(response); } else { - reject( - new Error( - `HTTP 请求失败: ${response.status} ${ - response.statusText || "" - }` - ) - ); + reject(new Error(`HTTP 请求失败: ${response.status} ${response.statusText || ""}`)); } }, onerror: (response) => { - reject( - new Error( - `GM_xmlhttpRequest 网络错误: ${ - response?.error || - response?.statusText || - "未知错误" - }` - ) - ); + reject(new Error(`GM_xmlhttpRequest 网络错误: ${response?.error || response?.statusText || "未知错误"}`)); }, ontimeout: () => { @@ -64,26 +50,14 @@ try { body = JSON.parse(response.responseText); } catch (error) { - throw new Error( - `mockhttp.org/headers 返回的内容不是有效 JSON: ${ - response.responseText - }` - ); + throw new Error(`mockhttp.org/headers 返回的内容不是有效 JSON: ${response.responseText}`); } if (!body || typeof body !== "object" || Array.isArray(body)) { - throw new Error( - `mockhttp.org/headers 返回了非预期响应: ${ - response.responseText - }` - ); + throw new Error(`mockhttp.org/headers 返回了非预期响应: ${response.responseText}`); } - if ( - body.headers && - typeof body.headers === "object" && - !Array.isArray(body.headers) - ) { + if (body.headers && typeof body.headers === "object" && !Array.isArray(body.headers)) { return body.headers; } @@ -104,10 +78,7 @@ function getCookieHeader(response) { const headers = getResponseHeadersBody(response); - const cookieHeader = getHeaderCaseInsensitive( - headers, - "cookie" - ); + const cookieHeader = getHeaderCaseInsensitive(headers, "cookie"); if (cookieHeader == null) { return ""; @@ -141,13 +112,9 @@ continue; } - const name = trimmed - .slice(0, separatorIndex) - .trim(); + const name = trimmed.slice(0, separatorIndex).trim(); - const value = trimmed - .slice(separatorIndex + 1) - .trim(); + const value = trimmed.slice(separatorIndex + 1).trim(); if (!name) { continue; @@ -163,12 +130,7 @@ return map; } - function assertCookieValues( - map, - name, - expectedValues, - message - ) { + function assertCookieValues(map, name, expectedValues, message) { const actual = (map.get(name) || []).slice().sort(); const expected = expectedValues.slice().sort(); @@ -186,13 +148,11 @@ const SUB = "/headers"; function setCookie(name, value, path) { - document.cookie = - `${name}=${value}; path=${path}; SameSite=Lax`; + document.cookie = `${name}=${value}; path=${path}; SameSite=Lax`; } function clearCookie(name, path) { - document.cookie = - `${name}=; path=${path}; max-age=0; SameSite=Lax`; + document.cookie = `${name}=; path=${path}; max-age=0; SameSite=Lax`; } // 测试 Cookie 名称使用 mBS 格式: @@ -200,17 +160,7 @@ // B = 浏览器已有的同名 Cookie 数量:0、1、2 // S = GM_xmlhttpRequest cookie 参数指定的值数量: // 0 表示未指定,1 表示单值,2 表示多值 - const NAMES = [ - "m00", - "m01", - "m02", - "m10", - "m11", - "m12", - "m20", - "m21", - "m22", - ]; + const NAMES = ["m00", "m01", "m02", "m10", "m11", "m12", "m20", "m21", "m22"]; function resetCookies() { for (const name of NAMES) { @@ -255,7 +205,8 @@ let matrixOk = false; describe("TM #2754: 同名 cookie 应覆盖而非追加", () => { - it( + check( + "自动断言", "document.cookie=data=1,GM_xhr cookie: data=2 时应只送出 data=2", async () => { setCookie("data", "1", ROOT); @@ -269,24 +220,22 @@ }); const cookieHeader = getCookieHeader(response); - const cookieMap = - parseCookieMultiMap(cookieHeader); - - assertCookieValues( - cookieMap, - "data", - ["2"], - "data 应只保留脚本指定的值" - ); + const cookieMap = parseCookieMultiMap(cookieHeader); + + assertCookieValues(cookieMap, "data", ["2"], "data 应只保留脚本指定的值"); } finally { clearCookie("data", ROOT); } - } + }, + null, + null, + "保留原有断言体" ); }); describe("TM #2829: 多个不同名 cookie 不应被截断", () => { - it( + check( + "自动断言", 'GM_xhr cookie: "data1=1; data2=2" 应两者都送出,而非只剩第一个', async () => { const response = await gmRequest({ @@ -297,131 +246,164 @@ }); const cookieHeader = getCookieHeader(response); - const cookieMap = - parseCookieMultiMap(cookieHeader); - - assertCookieValues( - cookieMap, - "data1", - ["1"], - "data1 应存在" - ); - - assertCookieValues( - cookieMap, - "data2", - ["2"], - "data2 不应被截断丢失" - ); - } + const cookieMap = parseCookieMultiMap(cookieHeader); + + assertCookieValues(cookieMap, "data1", ["1"], "data1 应存在"); + + assertCookieValues(cookieMap, "data2", ["2"], "data2 不应被截断丢失"); + }, + null, + null, + "保留原有断言体" ); }); describe("完整矩阵:浏览器已有(0/1/2) × 脚本指定(0/1/2)", () => { - it("发送带完整矩阵 cookie 参数的请求", async () => { - const customCookie = [ - "m01=new", - "m02=new1", - "m02=new2", - "m11=new", - "m12=new1", - "m12=new2", - "m21=new", - "m22=new1", - "m22=new2", - ].join("; "); - - const response = await gmRequest({ - method: "GET", - url: `${MOCKHTTP}/headers`, - cookie: customCookie, - timeout: 15000, - }); + check( + "自动断言", + "发送带完整矩阵 cookie 参数的请求", + async () => { + const customCookie = [ + "m01=new", + "m02=new1", + "m02=new2", + "m11=new", + "m12=new1", + "m12=new2", + "m21=new", + "m22=new1", + "m22=new2", + ].join("; "); - const cookieHeader = getCookieHeader(response); + const response = await gmRequest({ + method: "GET", + url: `${MOCKHTTP}/headers`, + cookie: customCookie, + timeout: 15000, + }); - lastCookieMap = - parseCookieMultiMap(cookieHeader); + const cookieHeader = getCookieHeader(response); - expect(cookieHeader.length > 0).toBeTruthy(); + lastCookieMap = parseCookieMultiMap(cookieHeader); - expect(lastCookieMap.size > 0).toBeTruthy(); + expect(cookieHeader.length > 0).toBeTruthy(); - matrixOk = true; - }); + expect(lastCookieMap.size > 0).toBeTruthy(); - it("m00:浏览器无、脚本未指定 → 不应出现", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues(lastCookieMap, "m00", []); - }); + matrixOk = true; + }, + null, + null, + "保留原有断言体" + ); - it("m01:浏览器无、脚本指定单值 → 应为脚本值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues( - lastCookieMap, - "m01", - ["new"] - ); - }); + check( + "自动断言", + "m00:浏览器无、脚本未指定 → 不应出现", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m00", []); + }, + null, + null, + "保留原有断言体" + ); - it("m02:浏览器无、脚本指定多值 → 应为脚本两个值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues(lastCookieMap, "m02", [ - "new1", - "new2", - ]); - }); + check( + "自动断言", + "m01:浏览器无、脚本指定单值 → 应为脚本值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m01", ["new"]); + }, + null, + null, + "保留原有断言体" + ); - it("m10:浏览器单值、脚本未指定 → 应保留浏览器原值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues( - lastCookieMap, - "m10", - ["old"] - ); - }); + check( + "自动断言", + "m02:浏览器无、脚本指定多值 → 应为脚本两个值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m02", ["new1", "new2"]); + }, + null, + null, + "保留原有断言体" + ); - it("m11:浏览器单值、脚本指定单值 → 应覆盖为脚本值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues( - lastCookieMap, - "m11", - ["new"] - ); - }); + check( + "自动断言", + "m10:浏览器单值、脚本未指定 → 应保留浏览器原值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m10", ["old"]); + }, + null, + null, + "保留原有断言体" + ); - it("m12:浏览器单值、脚本指定多值 → 应覆盖为脚本两个值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues(lastCookieMap, "m12", [ - "new1", - "new2", - ]); - }); + check( + "自动断言", + "m11:浏览器单值、脚本指定单值 → 应覆盖为脚本值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m11", ["new"]); + }, + null, + null, + "保留原有断言体" + ); - it("m20:浏览器多值(同名不同path)、脚本未指定 → 应保留浏览器全部值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues(lastCookieMap, "m20", [ - "old1", - "old2", - ]); - }); + check( + "自动断言", + "m12:浏览器单值、脚本指定多值 → 应覆盖为脚本两个值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m12", ["new1", "new2"]); + }, + null, + null, + "保留原有断言体" + ); - it("m21:浏览器多值、脚本指定单值 → 应完全覆盖为脚本单一值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues( - lastCookieMap, - "m21", - ["new"] - ); - }); + check( + "自动断言", + "m20:浏览器多值(同名不同path)、脚本未指定 → 应保留浏览器全部值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m20", ["old1", "old2"]); + }, + null, + null, + "保留原有断言体" + ); - it("m22:浏览器多值、脚本指定多值 → 应完全覆盖为脚本两个值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues(lastCookieMap, "m22", [ - "new1", - "new2", - ]); - }); + check( + "自动断言", + "m21:浏览器多值、脚本指定单值 → 应完全覆盖为脚本单一值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m21", ["new"]); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "m22:浏览器多值、脚本指定多值 → 应完全覆盖为脚本两个值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m22", ["new1", "new2"]); + }, + null, + null, + "保留原有断言体" + ); }); try { diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js index 5234ad05b..3a8ed4279 100644 --- a/example/tests/gm_xhr_redirect_test.js +++ b/example/tests/gm_xhr_redirect_test.js @@ -16,7 +16,7 @@ const enableTool = true; "use strict"; if (!enableTool) return; - const { describe, it, expect, run } = SCTest.create({ name: "GM_xhr 重定向测试" }); + const { describe, check, expect, run } = SCTest.create({ name: "GM_xhr 重定向测试" }); // ---------- Request helper ---------- function gmRequest(details, { abortAfterMs } = {}) { @@ -24,13 +24,18 @@ const enableTool = true; const t0 = performance.now(); const req = GM_xmlhttpRequest({ ...details, - onload: res => resolve({ kind: "load", res, ms: performance.now() - t0 }), - onerror: res => reject ({ kind: "error", res, ms: performance.now() - t0 }), - ontimeout: res => reject ({ kind: "timeout", res, ms: performance.now() - t0 }), - onabort: res => reject ({ kind: "abort", res, ms: performance.now() - t0 }), + onload: (res) => resolve({ kind: "load", res, ms: performance.now() - t0 }), + onerror: (res) => reject({ kind: "error", res, ms: performance.now() - t0 }), + ontimeout: (res) => reject({ kind: "timeout", res, ms: performance.now() - t0 }), + onabort: (res) => reject({ kind: "abort", res, ms: performance.now() - t0 }), onprogress: details.onprogress, }); - if (abortAfterMs != null) setTimeout(() => { try { req.abort(); } catch (_) {} }, abortAfterMs); + if (abortAfterMs != null) + setTimeout(() => { + try { + req.abort(); + } catch (_) {} + }, abortAfterMs); }); } @@ -39,12 +44,24 @@ const enableTool = true; function objectProps(o) { if (!o || typeof o !== "object") return "not an object"; let z, oD, zD; - try { z = Object.assign({}, o); } catch { return "Object.assign failed"; } - if (typeof (z.response ?? "") !== "string") return "non-primitive response value exposed"; + try { + z = Object.assign({}, o); + } catch { + return "Object.assign failed"; + } + if (typeof (z.response ?? "") !== "string") return "non-primitive response value exposed"; if (typeof (z.responseText ?? "") !== "string") return "non-primitive responseText value exposed"; - if (typeof (z.responseXML ?? "") !== "string") return "non-primitive responseXML value exposed"; - try { oD = JSON.stringify(o); } catch { return "JSON.stringify failed"; } - try { zD = JSON.stringify(z); } catch { return "JSON.stringify failed"; } + if (typeof (z.responseXML ?? "") !== "string") return "non-primitive responseXML value exposed"; + try { + oD = JSON.stringify(o); + } catch { + return "JSON.stringify failed"; + } + try { + zD = JSON.stringify(z); + } catch { + return "JSON.stringify failed"; + } if (oD !== zD) return "Object Props Failed"; return "ok"; } @@ -52,9 +69,14 @@ const enableTool = true; // ---------- Tests ---------- const basicTests = [ { - name: 'GET basic with search params 1', + name: "GET basic with search params 1", async run(fetch) { - const { res } = await gmRequest({ method: "GET", url: `${HB}/get?testing=234&abc=567`, responseType: "json", fetch }); + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/get?testing=234&abc=567`, + responseType: "json", + fetch, + }); expect(res.status).toBe(200); expect(res.response?.args?.testing?.[0]).toBe("234"); expect(res.response?.args?.abc?.[0]).toBe("567"); @@ -63,9 +85,14 @@ const enableTool = true; }, }, { - name: 'GET basic with search params 2', + name: "GET basic with search params 2", async run(fetch) { - const { res } = await gmRequest({ method: "GET", url: `${HB}/get?abc=567&testing=234`, responseType: "json", fetch }); + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/get?abc=567&testing=234`, + responseType: "json", + fetch, + }); expect(res.status).toBe(200); expect(res.response?.args?.testing?.[0]).toBe("234"); expect(res.response?.args?.abc?.[0]).toBe("567"); @@ -77,7 +104,11 @@ const enableTool = true; name: "Redirect handling (finalUrl changes) [default]", async run(fetch) { const target = `${HB}/get?z=92`; - const { res } = await gmRequest({ method: "GET", url: `${HB}/redirect-to?url=${encodeURIComponent(target)}`, fetch }); + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/redirect-to?url=${encodeURIComponent(target)}`, + fetch, + }); expect(res.status).toBe(200); expect(res.finalUrl).toBe(target); expect(objectProps(res)).toBe("ok"); @@ -87,7 +118,12 @@ const enableTool = true; name: "Redirect handling (finalUrl changes) [follow]", async run(fetch) { const target = `${HB}/get?z=94`; - const { res } = await gmRequest({ method: "GET", url: `${HB}/redirect-to?url=${encodeURIComponent(target)}`, redirect: "follow", fetch }); + const { res } = await gmRequest({ + method: "GET", + url: `${HB}/redirect-to?url=${encodeURIComponent(target)}`, + redirect: "follow", + fetch, + }); expect(res.status).toBe(200); expect(res.finalUrl).toBe(target); expect(objectProps(res)).toBe("ok"); @@ -98,8 +134,13 @@ const enableTool = true; async run(fetch) { try { await Promise.race([ - gmRequest({ method: "GET", url: `${HB}/redirect-to?url=${encodeURIComponent(`${HB}/get?z=96`)}`, redirect: "error", fetch }), - new Promise(resolve => setTimeout(resolve, 4000)), + gmRequest({ + method: "GET", + url: `${HB}/redirect-to?url=${encodeURIComponent(`${HB}/get?z=96`)}`, + redirect: "error", + fetch, + }), + new Promise((resolve) => setTimeout(resolve, 4000)), ]); throw new Error("Expected error, got load"); } catch (e) { @@ -117,7 +158,7 @@ const enableTool = true; const url = `${HB}/redirect-to?url=${encodeURIComponent(`${HB}/get?z=98`)}`; const { res } = await Promise.race([ gmRequest({ method: "GET", url, redirect: "manual", fetch }), - new Promise(resolve => setTimeout(resolve, 4000)), + new Promise((resolve) => setTimeout(resolve, 4000)), ]); expect(res?.status).toBe(301); expect(res?.finalUrl).toBe(url); @@ -127,15 +168,12 @@ const enableTool = true; }, ]; - const tests = [ - ...basicTests, - ...basicTests.map(t => ({ ...t, useFetch: true })), - ]; + const tests = [...basicTests, ...basicTests.map((t) => ({ ...t, useFetch: true }))]; describe("GM_xmlhttpRequest 重定向", () => { for (const t of tests) { const label = `${t.useFetch ? "[fetch] " : "[xhr] "}${t.name}`; - it(label, () => t.run(t.useFetch ? true : false)); + check("自动断言", label, () => t.run(t.useFetch ? true : false), null, null, "保留原有断言体"); } }); diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index df5b76754..931b213f1 100644 --- a/example/tests/gm_xhr_test.js +++ b/example/tests/gm_xhr_test.js @@ -49,7 +49,7 @@ const enableTool = true; "use strict"; if (!enableTool) return; - const { describe, it, expect, run } = SCTest.create({ name: "GM_xmlhttpRequest 完整测试" }); + const { describe, check, expect, run } = SCTest.create({ name: "GM_xmlhttpRequest 完整测试" }); // value type helper const typing = (x) => { @@ -856,18 +856,14 @@ const enableTool = true; ); expect(abort.events).toEqual(["onabort", "onloadend"]); - const nwError1 = await runCase( - { - url: `https://nonexistent-domain-abcxyz.test/abc.html`, // allowed domain - } - ); + const nwError1 = await runCase({ + url: `https://nonexistent-domain-abcxyz.test/abc.html`, // allowed domain + }); expect(nwError1.events).toEqual(["onerror", "onloadend"]); - const nwError2 = await runCase( - { - url: `https://nonexistent-domain-abcxyz.reject/abc.html`, // disallowed domain - } - ); + const nwError2 = await runCase({ + url: `https://nonexistent-domain-abcxyz.reject/abc.html`, // disallowed domain + }); expect(nwError2.events).toEqual(["onerror", "onloadend"]); }, }, @@ -1367,16 +1363,18 @@ const enableTool = true; const resultList = [...resultSet]; if (!fetch) { expect(progressCount >= 2).toBe(true); - expect(resultList).toEqual([ - "onreadystatechange 1.000;r=missing;t=missing;x=missing", - "onreadystatechange 2.200;r=missing;t=missing;x=missing", - "onreadystatechange 3.200;r=missing;t=missing;x=missing", - "onprogress 3.200;r=missing;t=missing;x=missing", - isFirefox ? "" : "onprogress 4.200;r=missing;t=missing;x=missing", - "onreadystatechange 4.200;r=;t=string;x=XMLDocument", - "onload 4.200;r=;t=string;x=XMLDocument", - "onloadend 4.200;r=;t=string;x=XMLDocument", - ].filter(Boolean)); + expect(resultList).toEqual( + [ + "onreadystatechange 1.000;r=missing;t=missing;x=missing", + "onreadystatechange 2.200;r=missing;t=missing;x=missing", + "onreadystatechange 3.200;r=missing;t=missing;x=missing", + "onprogress 3.200;r=missing;t=missing;x=missing", + isFirefox ? "" : "onprogress 4.200;r=missing;t=missing;x=missing", + "onreadystatechange 4.200;r=;t=string;x=XMLDocument", + "onload 4.200;r=;t=string;x=XMLDocument", + "onloadend 4.200;r=;t=string;x=XMLDocument", + ].filter(Boolean) + ); } else { expect(progressCount >= 2).toBe(true); expect(resultList).toEqual([ @@ -1423,16 +1421,18 @@ const enableTool = true; const resultList = [...resultSet]; if (!fetch) { expect(progressCount >= 2).toBe(true); - expect(resultList).toEqual([ - "onreadystatechange 1.000;r=missing;t=missing;x=missing", - "onreadystatechange 2.200;r=missing;t=missing;x=missing", - "onreadystatechange 3.200;r=missing;t=missing;x=missing", - "onprogress 3.200;r=missing;t=missing;x=missing", - isFirefox ? "" : "onprogress 4.200;r=missing;t=missing;x=missing", - "onreadystatechange 4.200;r=object;t=string;x=XMLDocument", - "onload 4.200;r=object;t=string;x=XMLDocument", - "onloadend 4.200;r=object;t=string;x=XMLDocument", - ].filter(Boolean)); + expect(resultList).toEqual( + [ + "onreadystatechange 1.000;r=missing;t=missing;x=missing", + "onreadystatechange 2.200;r=missing;t=missing;x=missing", + "onreadystatechange 3.200;r=missing;t=missing;x=missing", + "onprogress 3.200;r=missing;t=missing;x=missing", + isFirefox ? "" : "onprogress 4.200;r=missing;t=missing;x=missing", + "onreadystatechange 4.200;r=object;t=string;x=XMLDocument", + "onload 4.200;r=object;t=string;x=XMLDocument", + "onloadend 4.200;r=object;t=string;x=XMLDocument", + ].filter(Boolean) + ); } else { expect(progressCount >= 2).toBe(true); expect(resultList).toEqual([ @@ -1477,7 +1477,9 @@ const enableTool = true; ); const headers = resultHeaders; expect(headers.get("content-type")).toBe("application/json; charset=utf-8"); - expect(headers.get("reporting-endpoints").replace(/context=[-+\w]+/, "context=eJzj4tD")).toBe('default="/_/TranslateApiHttp/web-reports?context=eJzj4tD"'); + expect(headers.get("reporting-endpoints").replace(/context=[-+\w]+/, "context=eJzj4tD")).toBe( + 'default="/_/TranslateApiHttp/web-reports?context=eJzj4tD"' + ); expect(headers.get("cross-origin-opener-policy")).toBe("same-origin"); expect(headers.get("content-encoding") !== "deflate").toBe(true); }, @@ -1516,7 +1518,9 @@ const enableTool = true; ); const headers = resultHeaders; expect(headers.get("content-type")).toBe("application/json; charset=utf-8"); - expect(headers.get("reporting-endpoints").replace(/context=[-+\w]+/, "context=eJzj4tD")).toBe('default="/_/TranslateApiHttp/web-reports?context=eJzj4tD"'); + expect(headers.get("reporting-endpoints").replace(/context=[-+\w]+/, "context=eJzj4tD")).toBe( + 'default="/_/TranslateApiHttp/web-reports?context=eJzj4tD"' + ); expect(headers.get("cross-origin-opener-policy")).toBe("same-origin"); expect(headers.get("content-encoding") === "deflate" || headers.get("content-encoding") === null).toBe(true); }, @@ -1586,7 +1590,14 @@ const enableTool = true; // 所以不随页面加载自动开跑,由面板的运行按钮触发。 describe("GM_xmlhttpRequest", { auto: false }, () => { for (const t of tests) { - it(`${t.useFetch ? "[fetch]" : "[xhr]"} ${t.name}`, () => t.run(t.useFetch ? true : false)); + check( + "自动断言", + `${t.useFetch ? "[fetch]" : "[xhr]"} ${t.name}`, + () => t.run(t.useFetch ? true : false), + null, + null, + "保留原有断言体" + ); } }); diff --git a/example/tests/inject_content_test.js b/example/tests/inject_content_test.js index 4d68fa9f7..4fa3fb52d 100644 --- a/example/tests/inject_content_test.js +++ b/example/tests/inject_content_test.js @@ -21,94 +21,171 @@ (async function () { "use strict"; - const { describe, it, expect, run } = SCTest.create({ name: "Inject-into content 环境测试" }); + const { describe, check, expect, run } = SCTest.create({ name: "Inject-into content 环境测试" }); describe("CSP绕过测试", () => { - it("CSP绕过 - 内联脚本", () => { - const script = document.createElement("script"); - script.textContent = 'console.log("Content环境绕过CSP测试");'; - document.head.appendChild(script); - expect(script.parentNode === document.head).toBeTruthy(); - }); + check( + "自动断言", + "CSP绕过 - 内联脚本", + () => { + const script = document.createElement("script"); + script.textContent = 'console.log("Content环境绕过CSP测试");'; + document.head.appendChild(script); + expect(script.parentNode === document.head).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("DOM操作 API 测试", () => { - it("GM_addElement", () => { - const element = GM_addElement("div", { - textContent: "GM_addElement测试元素", - style: "display:none;", - id: "gm-test-element", - }); - expect(element !== null && element !== undefined).toBeTruthy(); - expect(element.id).toBe("gm-test-element"); - expect(element.tagName).toBe("DIV"); - }); + check( + "自动断言", + "GM_addElement", + () => { + const element = GM_addElement("div", { + textContent: "GM_addElement测试元素", + style: "display:none;", + id: "gm-test-element", + }); + expect(element !== null && element !== undefined).toBeTruthy(); + expect(element.id).toBe("gm-test-element"); + expect(element.tagName).toBe("DIV"); + }, + null, + null, + "保留原有断言体" + ); - it("GM_addStyle", () => { - const styleElement = GM_addStyle(` + check( + "自动断言", + "GM_addStyle", + () => { + const styleElement = GM_addStyle(` .gm-style-test { color: #10b981 !important; } `); - expect(styleElement !== null && styleElement !== undefined).toBeTruthy(); - expect(styleElement.tagName === "STYLE" || styleElement.sheet).toBeTruthy(); - }); + expect(styleElement !== null && styleElement !== undefined).toBeTruthy(); + expect(styleElement.tagName === "STYLE" || styleElement.sheet).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM_log 测试", () => { - it("GM_log", () => { - GM_log("测试日志输出", "info", { type: "test", value: 123 }); - // GM_log本身不返回值,只要不抛出异常就算成功 - expect(true).toBeTruthy(); - }); + check( + "自动断言", + "GM_log", + () => { + GM_log("测试日志输出", "info", { type: "test", value: 123 }); + // GM_log本身不返回值,只要不抛出异常就算成功 + expect(true).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM_info 测试", () => { - it("GM_info", () => { - expect(typeof GM_info === "object").toBeTruthy(); - expect(!!GM_info.script).toBeTruthy(); - expect(!!GM_info.script.name).toBeTruthy(); - }); + check( + "自动断言", + "GM_info", + () => { + expect(typeof GM_info === "object").toBeTruthy(); + expect(!!GM_info.script).toBeTruthy(); + expect(!!GM_info.script.name).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM 存储 API 测试", () => { - it("GM_setValue - 字符串", async () => { - await GM.setValue("test_key", "content环境测试值"); - const value = GM_getValue("test_key"); - expect(value).toBe("content环境测试值"); - }); + check( + "自动断言", + "GM_setValue - 字符串", + async () => { + await GM.setValue("test_key", "content环境测试值"); + const value = GM_getValue("test_key"); + expect(value).toBe("content环境测试值"); + }, + null, + null, + "保留原有断言体" + ); - it("GM_setValue - 数字", () => { - GM_setValue("test_number", 12345); - const value = GM_getValue("test_number"); - expect(value).toBe(12345); - }); + check( + "自动断言", + "GM_setValue - 数字", + () => { + GM_setValue("test_number", 12345); + const value = GM_getValue("test_number"); + expect(value).toBe(12345); + }, + null, + null, + "保留原有断言体" + ); - it("GM_setValue - 对象", () => { - const obj = { name: "ScriptCat", type: "content" }; - GM_setValue("test_object", obj); - const value = GM_getValue("test_object", {}); - expect(value.name).toBe("ScriptCat"); - expect(value.type).toBe("content"); - }); + check( + "自动断言", + "GM_setValue - 对象", + () => { + const obj = { name: "ScriptCat", type: "content" }; + GM_setValue("test_object", obj); + const value = GM_getValue("test_object", {}); + expect(value.name).toBe("ScriptCat"); + expect(value.type).toBe("content"); + }, + null, + null, + "保留原有断言体" + ); - it("GM_getValue - 默认值", () => { - const value = GM_getValue("non_existent_key", "默认值"); - expect(value).toBe("默认值"); - }); + check( + "自动断言", + "GM_getValue - 默认值", + () => { + const value = GM_getValue("non_existent_key", "默认值"); + expect(value).toBe("默认值"); + }, + null, + null, + "保留原有断言体" + ); - it("GM_listValues", () => { - const keys = GM_listValues(); - expect(Array.isArray(keys)).toBeTruthy(); - expect(keys.length >= 3).toBeTruthy(); - }); + check( + "自动断言", + "GM_listValues", + () => { + const keys = GM_listValues(); + expect(Array.isArray(keys)).toBeTruthy(); + expect(keys.length >= 3).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); - it("GM_deleteValue", () => { - GM_setValue("test_delete", "to_be_deleted"); - expect(GM_getValue("test_delete")).toBe("to_be_deleted"); - GM_deleteValue("test_delete"); - expect(GM_getValue("test_delete", null)).toBe(null); - }); + check( + "自动断言", + "GM_deleteValue", + () => { + GM_setValue("test_delete", "to_be_deleted"); + expect(GM_getValue("test_delete")).toBe("to_be_deleted"); + GM_deleteValue("test_delete"); + expect(GM_getValue("test_delete", null)).toBe(null); + }, + null, + null, + "保留原有断言体" + ); }); await run(); diff --git a/example/tests/lib/README.md b/example/tests/lib/README.md index e96013837..54e8cd1d1 100644 --- a/example/tests/lib/README.md +++ b/example/tests/lib/README.md @@ -1,6 +1,7 @@ # sctest — example/tests 共用测试框架 -零依赖、零构建的单文件测试框架,供 `example/tests/` 下的用户脚本共用。 +零依赖、零构建的单文件测试框架,供 `example/tests/` +下的用户脚本共用。每条结果都遵循同一份诊断协议,可同时被人类、DevTools、`GM_log` 和 E2E 读取。 ## 引入 @@ -8,78 +9,110 @@ // @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js ``` -e2e 运行时该框架 URL 会被自动重写到本地 mock server(见 `e2e/gm-api.spec.ts` 的 `patchRequireCode`), -因此框架本身始终使用工作区版本;脚本声明的其他 `@require`/`@resource` 依赖仍按各自 URL 加载。 +E2E 运行时会把该框架 URL 重写到本地 mock server(见 `e2e/gm-api.spec.ts` 的 +`patchRequireCode`),因此框架本身始终使用当前工作区版本;脚本声明的其他 `@require` / `@resource` +依赖仍按各自 URL 加载。 ## 用法 ```js -const { describe, it, itManual, expect, run } = SCTest.create({ name: "GM API 同步" }); +const { describe, check, note, itManual, run } = SCTest.create({ name: "GM API 同步" }); describe("GM 存储 API", () => { - it("GM_setValue 写入字符串", () => { - GM_setValue("k", "v"); - expect(GM_getValue("k")).toBe("v"); - }); - - it("支持异步用例", async () => { - const value = await GM.getValue("k"); - expect(value).toBe("v"); - }); + check( + "自动断言", + "GM_setValue 写入字符串", + () => { + GM_setValue("k", "v"); + return GM_getValue("k") === "v"; + }, + "v", + GM_getValue("k"), + "写入后读取到相同值" + ); + + check( + "异步断言", + "GM.getValue 读取字符串", + async () => (await GM.getValue("k")) === "v", + "v", + () => GM.getValue("k"), + "异步 predicate 返回 true" + ); + + note("运行信息", "当前页面", location.href, location.href, "记录环境,不产生 PASS/FAIL 断言"); }); -// 有副作用的组:默认不自动跑,点面板「运行全部」才启动 +// 有副作用的组:默认不自动跑,点面板「运行全部」才启动。 describe("GM_download", { auto: false, params: { prefix: "sc-test-" } }, () => { - it("下载文件", async () => { /* ... */ }); + check("下载操作", "下载文件", async () => true, "文件已落盘", "待检查", "保留原操作流程"); }); -// 需要人工操作的用例 +// 需要人工操作的用例保持独立的 MANUAL 状态。 describe("GM_registerMenuCommand", () => { - itManual("点击「测试命令 A」后弹出提示", { hint: "打开扩展图标 → 脚本菜单 → 点击「测试命令 A」" }); + itManual("点击测试命令后触发回调", { hint: "打开扩展图标 → 脚本菜单 → 点击测试命令" }); }); run(); ``` -## 断言 +`check(category, name, predicate, expected, actual, detail, options)` 支持异步 predicate。predicate 显式返回 `false` 为 +`FAIL`,其他正常返回值(包括旧断言体常见的 `undefined`)为 +`PASS`;异常也会记录到结果,而不是静默吞掉。`expected`、`actual`、`detail` 可传值或在用例执行后求值的函数。`options` +支持: -统一 `expect(actual).matcher(expected)`,**实际值在前**。 +- `onFail: "WARN"`:predicate 为假时记为 `WARN`; +- `onError: "WARN"`:predicate 抛异常时记为 `WARN`; +- `required: false`:把结果标为非必需观察项,仍保留原始状态和诊断字段。 -| matcher | 说明 | -|---|---| -| `toBe(expected)` | `!==` 严格比较 | -| `toEqual(expected)` | 结构化递归深比较(键顺序不敏感;区分 NaN/null、undefined 键) | -| `toBeTruthy()` | 真值 | -| `toBeTypeOf(type)` | `typeof` 比较 | -| `toMatch(pattern)` | 正则或子串 | -| `toThrow(pattern?)` | 被测目标须为函数;可选校验异常消息 | +`note(category, name, expected, actual, detail)` 只登记一条 `INFO` 观察记录,不伪造自动断言。 -## 主动跳过 +## 结果状态与兼容入口 -条件不满足时用 `SCTest.skip(reason)` 从用例体内退出,记为跳过而非失败,原因会出现在 -控制台、面板与 `GM_log` 里: +统一状态为 `PASS`、`FAIL`、`WARN`、`INFO`、`SKIP`、`MANUAL`。`MANUAL` +是独立的待人工裁决状态:在面板点击「通过」或「失败」后,原记录才会变成对应结果,并同步到 Console、`GM_log` +和 JSON 报告;未确认的人工用例不计入自动通过。 + +现有脚本可以继续使用: + +- `it(name, fn)`:兼容旧断言体,断言抛异常为 `FAIL`,正常返回(包括 `undefined`)为 `PASS`; +- `itManual(name, options)`:登记 `MANUAL`,`options.hint` 会显示操作说明; +- `expect(actual).toBe(...)`、`toEqual(...)`、`toBeTruthy()`、`toBeTypeOf(...)`、`toMatch(...)`、`toThrow(...)`; +- `SCTest.skip(reason)`:从用例内退出并登记 `SKIP`,原因会显示在所有 reporter。 + +## 自定义运行器 + +需要保留专用操作面板的脚本可使用 `SCTest.createReportSession()`,不必把下载、菜单或跨 iframe 操作 UI 改写成标准面板: ```js -it("需要浏览器原生下载", async () => { - const v = await awaitVerdict(); - if (v.verdict === "skip") SCTest.skip(`${v.reason} (未落盘)`); - expect(v.ok).toBeTruthy(); -}); +const report = SCTest.createReportSession({ name: "GM_download", reporter: "console" }); +report.start(); +const pending = report.manual("人工操作", "确认下载内容", "内容正确", "待检查", "打开文件并核对内容"); +// 操作完成后: +report.update(pending, "PASS", { actual: "内容正确", manualVerdict: "PASS" }); +report.finish(); ``` -不要靠约定错误消息前缀来表达跳过 —— 消息碰巧同名的真实错误会被一并吞掉。 +session 提供 `start()`、`record()`、`update()`、异步 `check()`、`note()`、`skip()`、`manual()`、`finish()` 和 +`summary()`。`finish()` 输出与标准 runner 相同的 JSON summary 协议。 + +## Reporter 与机器可读输出 -## 展示通道 +三个 reporter 可叠加,由 `SCTest.create({ reporter })` 控制,默认是 `"auto"`: -三个 reporter 可叠加,由 `SCTest.create({ reporter })` 控制,默认 `"auto"`: +| reporter | 启用条件 | 说明 | +| -------- | ----------------------------------- | ----------------------------------------------------------------------- | +| Console | 恒定开启 | DevTools 逐条输出状态、expected/actual/detail,末尾输出稳定 JSON marker | +| Panel | `page` 运行上下文 | Shadow DOM 浮层面板,宿主 id `sctest-panel-host` | +| Log | `background` / `crontab` 运行上下文 | `GM_log` 逐条输出,落到「运行日志」页 | -| reporter | auto 模式下的启用条件 | 说明 | -|---|---|---| -| Console | **恒定开启** | 全量输出,末尾三行汇总是 e2e 的解析契约,勿改格式 | -| Panel | 运行上下文为 `page` | Shadow DOM 浮层面板,宿主 id `sctest-panel-host` | -| Log | 运行上下文为 `background` / `crontab` | `GM_log` + 结构化 label,落到「运行日志」页 | +Console 的机器可读行以 `[SCTEST_RESULT] ` 开头,后面是 `protocol: "sctest/v1"` 的 JSON。summary 包含 +`counts.PASS`、`counts.FAIL`、`counts.WARN`、`counts.INFO`、`counts.SKIP`、`counts.MANUAL`,以及每条用例的 +`category`、`status`、`expected`、`actual`、`detail`、`required` 和 `manualVerdict`。E2E 只以 `FAIL` 判定自动失败; +`WARN`、`INFO`、`SKIP` 和尚未裁决的 `MANUAL` 必须保留并展示。 -运行上下文由 `GM_info.scriptMetaStr` 里的 `@background` / `@crontab` 判定 —— 后台脚本跑在 offscreen -文档里,`document` 是存在的,所以不能用 `typeof document === "undefined"` 判断。 +Panel 保留原有 +`sctest-panel-host`、CSP 防护、拖动、折叠、重跑和参数选择器,并提供状态 chips、分类分组、状态筛选、搜索、复制文本和复制 JSON。early-start 脚本应使用 +`{ reporter: "console" }`,避免 document-start 的 DOM 断言被面板初始化改变。 -用 `GM_log` 通道时脚本必须 `@grant GM_log`,否则日志会静默丢弃(Console 通道不受影响)。 +后台脚本若要使用 `GM_log`,必须声明 `@grant GM_log`;Console reporter 不依赖该权限。 diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js index be7b62184..111d99ee0 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -5,7 +5,15 @@ (function (global) { "use strict"; - var STATUS = { PASS: "pass", FAIL: "fail", SKIP: "skip", MANUAL: "manual" }; + var STATUS = { + PASS: "PASS", + FAIL: "FAIL", + WARN: "WARN", + INFO: "INFO", + SKIP: "SKIP", + MANUAL: "MANUAL", + }; + var SCTEST_MARKER = "[SCTEST_RESULT]"; // GM_info.script 不含 background/crontab 字段(见 src/app/service/content/gm_api/gm_info.ts), // 只能从 metadata 原文判断运行上下文。 @@ -151,22 +159,74 @@ return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); } + function normalizeStatus(status) { + var normalized = String(status || "").toUpperCase(); + return STATUS[normalized] || STATUS.FAIL; + } + + function resolveValue(value) { + return typeof value === "function" ? value() : value; + } + + function createSummary(name, context, suites, startedAt) { + var counts = { PASS: 0, FAIL: 0, WARN: 0, INFO: 0, SKIP: 0, MANUAL: 0 }; + var total = 0; + var outSuites = suites.map(function (s) { + return { + name: s.name, + auto: s.auto, + params: s.params, + cases: s.cases.map(function (c) { + total++; + counts[c.status] = (counts[c.status] || 0) + 1; + return { + name: c.name, + suite: c.suite, + category: c.category, + status: c.status, + durationMs: c.durationMs, + error: c.error, + expected: c.expected, + actual: c.actual, + detail: c.detail, + hint: c.hint, + required: c.required, + manualVerdict: c.manualVerdict || null, + }; + }), + }; + }); + return { + protocol: "sctest/v1", + name: name, + context: context, + total: total, + passed: counts.PASS, + failed: counts.FAIL, + warned: counts.WARN, + info: counts.INFO, + skipped: counts.SKIP, + manual: counts.MANUAL, + counts: counts, + overall: counts.FAIL ? STATUS.FAIL : counts.WARN ? STATUS.WARN : STATUS.PASS, + durationMs: Math.round(now() - startedAt), + suites: outSuites, + }; + } + function create(options) { var opts = options || {}; var runName = opts.name || "未命名测试"; var context = opts.context || detectContext(currentMetaStr()); var suites = []; var currentSuite = null; + var lastStartedAt = 0; + var runInfo = null; function describe(name, optsOrFn, maybeFn) { var suiteOpts = typeof optsOrFn === "function" ? {} : optsOrFn || {}; var fn = typeof optsOrFn === "function" ? optsOrFn : maybeFn; - var suite = { - name: name, - auto: suiteOpts.auto !== false, - params: suiteOpts.params || {}, - cases: [], - }; + var suite = { name: name, auto: suiteOpts.auto !== false, params: suiteOpts.params || {}, cases: [] }; suites.push(suite); currentSuite = suite; try { @@ -176,103 +236,148 @@ } } - function pushCase(name, fn, kind, hint) { - if (!currentSuite) throw new Error("it/itManual 必须写在 describe 内部:" + name); + function pushCase(category, name, fn, kind, fields) { + if (!currentSuite) throw new Error("check/it/itManual 必须写在 describe 内部:" + name); + var data = fields || {}; currentSuite.cases.push({ name: name, suite: currentSuite.name, + category: category || currentSuite.name, fn: fn, kind: kind, - hint: hint || "", + hint: data.hint || "", status: null, durationMs: 0, error: null, - expected: null, - actual: null, + expected: data.expected == null ? null : data.expected, + actual: data.actual == null ? null : data.actual, + detail: data.detail || "", + expectedSource: data.expected == null ? null : data.expected, + actualSource: data.actual == null ? null : data.actual, + detailSource: data.detail || "", + required: data.required !== false, + requiredSource: data.required !== false, + onFail: data.onFail, + onError: data.onError, + manualVerdict: null, + }); + } + + function check(category, name, predicate, expected, actual, detail, options) { + if (typeof name === "function") { + options = {}; + detail = "保留原有断言体"; + actual = null; + expected = null; + predicate = name; + name = category; + category = currentSuite ? currentSuite.name : "自动断言"; + } + pushCase(category, name, predicate, "check", { + expected: expected, + actual: actual, + detail: detail, + required: !options || options.required !== false, + onFail: options && options.onFail, + onError: options && options.onError, + }); + } + + function note(category, name, expected, actual, detail) { + pushCase(category, name, null, "note", { + expected: expected, + actual: actual, + detail: detail, + required: false, }); } function it(name, fn) { - pushCase(name, fn, "auto", ""); + check(name, fn); } function itManual(name, manualOpts) { - pushCase(name, null, "manual", (manualOpts || {}).hint); + pushCase(currentSuite ? currentSuite.name : "人工验证", name, null, "manual", { + hint: (manualOpts || {}).hint, + required: false, + }); } function toResult(c) { return { name: c.name, suite: c.suite, + category: c.category, status: c.status, durationMs: c.durationMs, error: c.error, expected: c.expected, actual: c.actual, + detail: c.detail, hint: c.hint, + required: c.required, + manualVerdict: c.manualVerdict || null, }; } + function emitCase(reporters, result) { + reporters.forEach(function (r) { + if (r.onCase) r.onCase(result); + }); + } + async function runCase(c, reporters) { c.error = null; - c.expected = null; - c.actual = null; + if (c.kind === "check") { + c.required = c.requiredSource; + c.expected = null; + c.actual = null; + c.detail = ""; + } if (c.kind === "manual") { c.status = STATUS.MANUAL; + } else if (c.kind === "note") { + c.status = STATUS.INFO; } else { var started = now(); try { - await c.fn(); - c.status = STATUS.PASS; + var passed = await c.fn(); + c.expected = resolveValue(c.expectedSource); + c.actual = resolveValue(c.actualSource); + // Existing assertion bodies return undefined after their expect() calls. Treat only + // an explicit false as a predicate failure so those bodies can migrate one-for-one. + var matched = passed !== false; + c.detail = resolveValue(c.detailSource) || (matched ? "符合预期" : "不符合预期"); + c.status = matched ? STATUS.PASS : normalizeStatus(c.onFail || STATUS.FAIL); } catch (e) { if (e instanceof SkipSignal) { c.status = STATUS.SKIP; c.error = e.reason; + c.required = false; + c.detail = c.detail || "当前环境未提供该检查"; } else { - c.status = STATUS.FAIL; + c.status = normalizeStatus(c.onError || c.onFail || STATUS.FAIL); c.error = String((e && e.message) || e); - c.expected = (e && e.expected) || null; - c.actual = (e && e.actual) || null; + if (c.expected == null) c.expected = resolveValue(c.expectedSource) || (e && e.expected) || null; + if (c.actual == null) c.actual = resolveValue(c.actualSource) || (e && e.actual) || null; + c.detail = resolveValue(c.detailSource) || "检测过程抛出异常"; } } c.durationMs = Math.round(now() - started); } var result = toResult(c); - reporters.forEach(function (r) { - if (r.onCase) r.onCase(result); - }); + emitCase(reporters, result); return result; } function buildSummary(startedAt) { - var total = 0; - var passed = 0; - var failed = 0; - var skipped = 0; - var outSuites = suites.map(function (s) { - return { - name: s.name, - auto: s.auto, - params: s.params, - cases: s.cases.map(function (c) { - total++; - if (c.status === STATUS.PASS) passed++; - else if (c.status === STATUS.FAIL) failed++; - else skipped++; - return toResult(c); - }), - }; + return createSummary(runName, context, suites, startedAt); + } + + function emitEnd(reporters, summary) { + reporters.forEach(function (r) { + if (r.onEnd) r.onEnd(summary); }); - return { - name: runName, - context: context, - total: total, - passed: passed, - failed: failed, - skipped: skipped, - durationMs: Math.round(now() - startedAt), - suites: outSuites, - }; } async function rerunSuites(reporters, onlySuiteName, includeAutoSuites) { @@ -289,18 +394,13 @@ await runCase(c, reporters); } } - // 手动 suite 的用例在 run() 主流程里只被标记为 skip,真实结果只在这里产生, - // 所以必须重新发一次 onEnd —— 否则 ConsoleReporter 的三行汇总(e2e 的解析契约) - // 和 LogReporter 的汇总日志对全部 auto:false 的文件永远不会出现。 var summary = buildSummary(startedAt); - reporters.forEach(function (r) { - if (r.onEnd) r.onEnd(summary); - }); + emitEnd(reporters, summary); return summary; } async function run() { - var runInfo = { name: runName, context: context, suites: suites, onRunManual: null }; + runInfo = { name: runName, context: context, suites: suites, onRunManual: null, onManualVerdict: null }; var reporters = global.SCTest.__buildReporters(opts, context, runInfo); runInfo.onRunManual = function (suiteName) { return rerunSuites(reporters, suiteName, false); @@ -308,7 +408,24 @@ runInfo.onRerun = function () { return rerunSuites(reporters, null, true); }; - var startedAt = now(); + runInfo.onManualVerdict = function (suiteName, caseName, status, detail) { + var target = null; + suites.forEach(function (suite) { + suite.cases.forEach(function (c) { + if (c.suite === suiteName && c.name === caseName) target = c; + }); + }); + if (!target || target.kind !== "manual") return buildSummary(lastStartedAt || now()); + target.status = normalizeStatus(status); + target.manualVerdict = target.status; + target.error = target.status === STATUS.FAIL ? detail || "人工确认失败" : null; + target.detail = detail || target.detail || "人工确认完成"; + emitCase(reporters, toResult(target)); + var summary = buildSummary(lastStartedAt || now()); + emitEnd(reporters, summary); + return summary; + }; + lastStartedAt = now(); reporters.forEach(function (r) { if (r.onStart) r.onStart(runInfo); }); @@ -319,24 +436,190 @@ var c = suite.cases[j]; if (!suite.auto && c.kind !== "manual") { c.status = STATUS.SKIP; - var skippedResult = toResult(c); - reporters.forEach(function (r) { - if (r.onCase) r.onCase(skippedResult); - }); + c.required = false; + emitCase(reporters, toResult(c)); continue; } await runCase(c, reporters); } } - var summary = buildSummary(startedAt); + var summary = buildSummary(lastStartedAt); + emitEnd(reporters, summary); + return summary; + } + + return { + describe: describe, + check: check, + note: note, + it: it, + itManual: itManual, + expect: makeExpect(), + run: run, + }; + } + + function createReportSession(options) { + var opts = options || {}; + var name = opts.name || "未命名报告"; + var context = opts.context || detectContext(currentMetaStr()); + var reporters = global.SCTest.__buildReporters({ reporter: opts.reporter || "console" }, context, { + name: name, + context: context, + suites: [], + }); + var cases = []; + var startedAt = now(); + var started = false; + var finished = false; + + function emitSummary() { + var summary = createSummary(name, context, [{ name: "报告", auto: true, params: {}, cases: cases }], startedAt); + reporters.forEach(function (r) { + if (r.onEnd) r.onEnd(summary); + }); + } + + function start() { + if (started) return; + started = true; + reporters.forEach(function (r) { + if (r.onStart) r.onStart({ name: name, context: context, suites: [] }); + }); + } + + function record(input) { + start(); + var result = { + category: input.category || "运行观察", + name: input.name || "未命名结果", + suite: input.suite || input.category || "运行观察", + status: normalizeStatus(input.status), + durationMs: input.durationMs || 0, + error: input.error || null, + expected: input.expected == null ? null : input.expected, + actual: input.actual == null ? null : input.actual, + detail: input.detail || "", + hint: input.hint || "", + required: input.required !== false, + manualVerdict: input.manualVerdict || null, + }; + cases.push(result); + reporters.forEach(function (r) { + if (r.onCase) r.onCase(result); + }); + if (finished) emitSummary(); + return result; + } + + function update(result, status, fields) { + var index = cases.indexOf(result); + if (index < 0) throw new Error("报告结果不属于当前 session"); + var next = fields || {}; + result.status = normalizeStatus(status); + Object.keys(next).forEach(function (key) { + result[key] = next[key]; + }); + reporters.forEach(function (r) { + if (r.onCase) r.onCase(result); + }); + if (finished) emitSummary(); + return result; + } + + async function checkSession(category, caseName, predicate, expected, actual, detail, options) { + var startedAtForCase = now(); + var checkOptions = options || {}; + try { + var passed = await predicate(); + return record({ + category: category, + name: caseName, + status: passed !== false ? STATUS.PASS : normalizeStatus(checkOptions.onFail || STATUS.FAIL), + expected: resolveValue(expected), + actual: resolveValue(actual), + detail: resolveValue(detail) || (passed ? "符合预期" : "不符合预期"), + required: checkOptions.required !== false, + durationMs: Math.round(now() - startedAtForCase), + }); + } catch (e) { + if (e instanceof SkipSignal) { + return record({ + category: category, + name: caseName, + status: STATUS.SKIP, + actual: "当前环境未提供", + detail: e.reason, + required: false, + durationMs: Math.round(now() - startedAtForCase), + }); + } + return record({ + category: category, + name: caseName, + status: normalizeStatus(checkOptions.onError || checkOptions.onFail || STATUS.FAIL), + expected: expected, + actual: "抛出 " + String((e && e.message) || e), + detail: checkOptions.errorDetail || resolveValue(detail) || "检测过程抛出异常", + required: checkOptions.required !== false, + durationMs: Math.round(now() - startedAtForCase), + }); + } + } + + function finish() { + start(); + var suite = { name: "报告", auto: true, params: {}, cases: cases }; + var summary = createSummary(name, context, [suite], startedAt); + finished = true; reporters.forEach(function (r) { if (r.onEnd) r.onEnd(summary); }); return summary; } - return { describe: describe, it: it, itManual: itManual, expect: makeExpect(), run: run }; + return { + start: start, + record: record, + update: update, + check: checkSession, + note: function (category, caseName, expected, actual, detail) { + return record({ + category: category, + name: caseName, + status: STATUS.INFO, + expected: expected, + actual: actual, + detail: detail, + required: false, + }); + }, + skip: function (category, caseName, expected, actual, detail) { + return record({ + category: category, + name: caseName, + status: STATUS.SKIP, + expected: expected, + actual: actual, + detail: detail, + required: false, + }); + }, + manual: function (category, caseName, expected, actual, detail) { + return record({ + category: category, + name: caseName, + status: STATUS.MANUAL, + expected: expected, + actual: actual, + detail: detail, + required: false, + }); + }, + finish: finish, + summary: finish, + }; } // ---------- ConsoleReporter ---------- @@ -351,23 +634,26 @@ lastSuite = c.suite; console.log("\n%c--- " + c.suite + " ---", "color: orange; font-weight: bold;"); } - if (c.status === STATUS.PASS) { - console.log("%c✓ " + c.name + " (" + c.durationMs + "ms)", "color: green;"); - } else if (c.status === STATUS.FAIL) { - console.error("%c✗ " + c.name, "color: red;", c.error); - } else if (c.status === STATUS.MANUAL) { - var hintSuffix = c.hint ? ":" + c.hint : ""; - console.log("%c○ " + c.name + " (待人工确认" + hintSuffix + ")", "color: #999;"); - } else { - console.log("%c○ " + c.name + " (跳过" + (c.error ? ": " + c.error : "") + ")", "color: #999;"); - } + var icon = ICONS[c.status] || "○"; + var detail = c.detail ? " — " + c.detail : ""; + var reason = c.error ? " — " + c.error : ""; + var fields = { expected: c.expected, actual: c.actual, detail: c.detail, required: c.required }; + var line = icon + " [" + c.status + "] " + c.name + " (" + c.durationMs + "ms)" + detail + reason; + if (c.status === STATUS.FAIL) console.error("%c" + line, "color: red;", fields); + else if (c.status === STATUS.WARN) console.warn("%c" + line, "color: #c46c00;", fields); + else if (c.status === STATUS.INFO) console.info("%c" + line, "color: #477;", fields); + else console.log("%c" + line, c.status === STATUS.PASS ? "color: green;" : "color: #777;", fields); }, onEnd: function (summary) { console.log("\n%c=== 测试完成 ===", "color: blue; font-weight: bold;"); console.log("总测试数: " + summary.total); console.log("%c通过: " + summary.passed, "color: green; font-weight: bold;"); console.log("%c失败: " + summary.failed, "color: red; font-weight: bold;"); - console.log("跳过: " + summary.skipped + " (" + summary.durationMs + "ms)"); + console.log("%c警告: " + summary.warned, "color: #c46c00; font-weight: bold;"); + console.log("信息: " + summary.info); + console.log("跳过: " + summary.skipped); + console.log("人工: " + summary.manual + " (" + summary.durationMs + "ms)"); + console.log(SCTEST_MARKER + " " + JSON.stringify(summary)); }, }; } @@ -401,10 +687,13 @@ ".sc-status{display:inline-flex;align-items:center;gap:5px;border-radius:9999px;padding:3px 10px;font-weight:600}", ".sc-status-pass{background:var(--sc-success-bg);color:var(--sc-success-fg)}", ".sc-status-fail{background:var(--sc-destructive-bg);color:var(--sc-destructive-fg)}", + ".sc-status-warn{background:var(--sc-warning-bg);color:var(--sc-warning-fg)}", + ".sc-status-info,.sc-status-skip,.sc-status-manual{background:var(--sc-muted-bg);color:var(--sc-muted)}", ".sc-chip{display:inline-flex;align-items:center;gap:4px;border-radius:9999px;padding:3px 9px;font-size:11px;font-weight:500}", ".sc-chip-pass{background:var(--sc-success-bg);color:var(--sc-success-fg)}", ".sc-chip-fail{background:var(--sc-destructive-bg);color:var(--sc-destructive-fg)}", - ".sc-chip-skip{background:var(--sc-muted-bg);color:var(--sc-muted)}", + ".sc-chip-warn{background:var(--sc-warning-bg);color:var(--sc-warning-fg)}", + ".sc-chip-info,.sc-chip-skip,.sc-chip-manual{background:var(--sc-muted-bg);color:var(--sc-muted)}", ".sc-progress{height:6px;border-radius:9999px;background:var(--sc-muted-bg);overflow:hidden;display:flex}", ".sc-progress i{display:block;height:6px}", ".sc-toolbar{padding:8px 14px;border-bottom:1px solid var(--sc-border)}", @@ -422,12 +711,15 @@ ".sc-suite-stat[data-manual='1']{display:inline-flex;align-items:center;gap:4px;background:var(--sc-warning-bg);color:var(--sc-warning-fg)}", ".sc-case{display:flex;align-items:center;gap:8px;padding:6px 14px 6px 34px}", ".sc-case span{flex:1}", + ".sc-case-label{min-width:0;display:flex;flex-direction:column;gap:2px}", + ".sc-case-category{color:var(--sc-muted);font-size:10px;font-weight:400}", + ".sc-case-status{font-size:10px;font-style:normal;font-weight:700}", ".sc-case-manual{background:var(--sc-warning-bg)}", ".sc-manual-pass{width:22px;height:22px;padding:0;border-color:var(--sc-success-fg);background:var(--sc-success-bg);color:var(--sc-success-fg)}", ".sc-manual-fail{width:22px;height:22px;padding:0;border-color:var(--sc-destructive-fg);background:var(--sc-destructive-bg);color:var(--sc-destructive-fg)}", ".sc-dur{font-size:11px;color:var(--sc-muted)}", - ".sc-detail{margin:0 14px 8px 34px;padding:8px 10px;border-radius:6px;border-left:2px solid var(--sc-destructive);", - "background:var(--sc-destructive-bg);color:var(--sc-destructive-fg);font-family:'JetBrains Mono',monospace;", + ".sc-detail{margin:0 14px 8px 34px;padding:8px 10px;border-radius:6px;border-left:2px solid var(--sc-border);", + "background:var(--sc-muted-bg);color:var(--sc-fg);font-family:'JetBrains Mono',monospace;", "font-size:11px;white-space:pre-wrap}", ".sc-hint{display:flex;gap:6px;margin:0 14px 8px 34px;padding:7px 10px;border-radius:6px;background:var(--sc-muted-bg);", "color:var(--sc-muted);font-size:11px}", @@ -467,7 +759,7 @@ root.appendChild(style); } - var ICONS = { pass: "✓", fail: "✗", skip: "○", manual: "✋" }; + var ICONS = { PASS: "✓", FAIL: "✗", WARN: "△", INFO: "ⓘ", SKIP: "○", MANUAL: "✋" }; function createPanelReporter(runInfo) { if (typeof document === "undefined" || !document.documentElement) return null; @@ -485,7 +777,7 @@ panel.className = "sc-panel"; root.appendChild(panel); - var state = { pass: 0, fail: 0, skip: 0, total: 0, durationMs: 0, manualOverrides: {} }; + var state = { pass: 0, fail: 0, warn: 0, info: 0, skip: 0, manual: 0, total: 0, durationMs: 0 }; var caseNodes = {}; var suiteNodes = {}; var activeFilter = "all"; @@ -517,8 +809,10 @@ "chevron-right": "m9 18 6-6-6-6", hand: "M18 11V6a2 2 0 0 0-4 0v5M14 10V4a2 2 0 0 0-4 0v7M10 10V5a2 2 0 0 0-4 0v9l-2-2a2 2 0 0 0-3 3l5 5a5 5 0 0 0 4 2h3a7 7 0 0 0 7-7v-3a2 2 0 0 0-4 0v-1", info: "M12 16v-4M12 8h.01M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0Z", - "clipboard-copy": "M9 5h6M9 3h6v4H9zM15 11h5v5M20 11l-7 7M9 21H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h2M16 3h2a2 2 0 0 1 2 2v3", - braces: "M8 3H7a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2 2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1M16 3h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2 2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-1", + "clipboard-copy": + "M9 5h6M9 3h6v4H9zM15 11h5v5M20 11l-7 7M9 21H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h2M16 3h2a2 2 0 0 1 2 2v3", + braces: + "M8 3H7a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2 2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1M16 3h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2 2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-1", }; var ICON_NODES = { @@ -643,15 +937,21 @@ statusRow.appendChild(duration); sum.appendChild(statusRow); var chips = el("div", "sc-chips"); - var chipPass = el("span", "sc-chip sc-chip-pass", "通过 0"); - var chipFail = el("span", "sc-chip sc-chip-fail", "失败 0"); - var chipSkip = el("span", "sc-chip sc-chip-skip", "跳过 0"); + var chipPass = el("span", "sc-chip sc-chip-pass", "PASS 0"); + var chipFail = el("span", "sc-chip sc-chip-fail", "FAIL 0"); + var chipWarn = el("span", "sc-chip sc-chip-warn", "WARN 0"); + var chipInfo = el("span", "sc-chip sc-chip-info", "INFO 0"); + var chipSkip = el("span", "sc-chip sc-chip-skip", "SKIP 0"); + var chipManual = el("span", "sc-chip sc-chip-manual", "MANUAL 0"); var chipTotal = el("span", "sc-chip sc-chip-skip", "共 0"); chipTotal.setAttribute("data-sctest", "total-chip"); [ [chipPass, "check"], [chipFail, "x"], + [chipWarn, "info"], + [chipInfo, "info"], [chipSkip, "minus"], + [chipManual, "hand"], [chipTotal, "hash"], ].forEach(function (entry) { entry[0].insertBefore(icon(entry[1], 11), entry[0].firstChild); @@ -659,7 +959,10 @@ chips.setAttribute("data-sctest", "counters"); chips.appendChild(chipPass); chips.appendChild(chipFail); + chips.appendChild(chipWarn); + chips.appendChild(chipInfo); chips.appendChild(chipSkip); + chips.appendChild(chipManual); chips.appendChild(chipTotal); var progress = el("div", "sc-progress"); progress.setAttribute("data-sctest", "progress"); @@ -669,12 +972,24 @@ var barFail = el("i"); barFail.style.background = "var(--sc-destructive)"; barFail.setAttribute("data-sctest", "progress-fail"); + var barWarn = el("i"); + barWarn.style.background = "var(--sc-warning-fg)"; + barWarn.setAttribute("data-sctest", "progress-warn"); + var barInfo = el("i"); + barInfo.style.background = "var(--sc-primary)"; + barInfo.setAttribute("data-sctest", "progress-info"); var barSkip = el("i"); barSkip.style.background = "var(--sc-muted)"; barSkip.setAttribute("data-sctest", "progress-skip"); + var barManual = el("i"); + barManual.style.background = "var(--sc-warning-bg)"; + barManual.setAttribute("data-sctest", "progress-manual"); progress.appendChild(barPass); progress.appendChild(barFail); + progress.appendChild(barWarn); + progress.appendChild(barInfo); progress.appendChild(barSkip); + progress.appendChild(barManual); sum.appendChild(progress); sum.appendChild(chips); panel.appendChild(sum); @@ -714,7 +1029,10 @@ ctl.appendChild(el("span", "sc-params-label", "参数")); var paramKeys = Object.keys(s.params); paramKeys.forEach(function (key, index) { - var field = el("label", "sc-field" + (index === paramKeys.length - 1 && paramKeys.length > 1 ? " sc-field-compact" : "")); + var field = el( + "label", + "sc-field" + (index === paramKeys.length - 1 && paramKeys.length > 1 ? " sc-field-compact" : "") + ); var input = document.createElement("input"); input.value = s.params[key]; input.setAttribute("data-sctest", "param-" + key); @@ -732,14 +1050,23 @@ var segments = el("div", "sc-segments"); var filterAll = el("button", "sc-segment", "全部"); var filterFail = el("button", "sc-segment", "失败"); + var filterWarn = el("button", "sc-segment", "警告"); + var filterInfo = el("button", "sc-segment", "信息"); var filterSkip = el("button", "sc-segment", "跳过"); + var filterManual = el("button", "sc-segment", "人工"); filterAll.dataset.active = "1"; filterAll.setAttribute("data-sctest", "filter-all"); filterFail.setAttribute("data-sctest", "filter-fail"); + filterWarn.setAttribute("data-sctest", "filter-warn"); + filterInfo.setAttribute("data-sctest", "filter-info"); filterSkip.setAttribute("data-sctest", "filter-skip"); + filterManual.setAttribute("data-sctest", "filter-manual"); segments.appendChild(filterAll); segments.appendChild(filterFail); + segments.appendChild(filterWarn); + segments.appendChild(filterInfo); segments.appendChild(filterSkip); + segments.appendChild(filterManual); var searchWrap = el("label", "sc-search"); searchWrap.setAttribute("data-sctest", "search"); searchWrap.appendChild(icon("search", 12)); @@ -782,7 +1109,14 @@ var lines = [sumLine.textContent]; Object.keys(caseNodes).forEach(function (key) { var node = caseNodes[key]; - lines.push((ICONS[node.status] || "○") + " " + key.replace("//", " › ")); + lines.push( + (ICONS[node.status] || "○") + + " [" + + node.status + + "] " + + key.replace("//", " › ") + + (node.result && node.result.detail ? " — " + node.result.detail : "") + ); }); return lines.join("\n"); } @@ -790,9 +1124,33 @@ function reportJson() { var cases = Object.keys(caseNodes).map(function (key) { var node = caseNodes[key]; - return { suite: node.suite, name: key.slice(key.indexOf("//") + 2), status: node.status }; + return node.result || { suite: node.suite, name: key.slice(key.indexOf("//") + 2), status: node.status }; }); - return { name: runInfo.name, context: runInfo.context, summary: state, cases: cases }; + return { + protocol: "sctest/v1", + name: runInfo.name, + context: runInfo.context, + summary: { + total: state.total, + passed: state.pass, + failed: state.fail, + warned: state.warn, + info: state.info, + skipped: state.skip, + manual: state.manual, + counts: { + PASS: state.pass, + FAIL: state.fail, + WARN: state.warn, + INFO: state.info, + SKIP: state.skip, + MANUAL: state.manual, + }, + overall: state.fail ? STATUS.FAIL : state.warn ? STATUS.WARN : STATUS.PASS, + durationMs: state.durationMs, + }, + cases: cases, + }; } function copyReport() { @@ -836,8 +1194,9 @@ var node = caseNodes[key]; var statusOk = activeFilter === "all" || - (activeFilter === "skip" && (node.status === "skip" || node.status === "manual")) || - node.status === activeFilter; + node.status === activeFilter || + (activeFilter === "skip" && node.status === STATUS.SKIP) || + (activeFilter === "manual" && node.status === STATUS.MANUAL); var textOk = !query || key.toLowerCase().indexOf(query) !== -1; node.row.hidden = !(statusOk && textOk); if (node.detail) node.detail.hidden = node.row.hidden; @@ -853,10 +1212,17 @@ }); } - [[filterAll, "all"], [filterFail, "fail"], [filterSkip, "skip"]].forEach(function (entry) { + [ + [filterAll, "all"], + [filterFail, STATUS.FAIL], + [filterWarn, STATUS.WARN], + [filterInfo, STATUS.INFO], + [filterSkip, STATUS.SKIP], + [filterManual, STATUS.MANUAL], + ].forEach(function (entry) { entry[0].addEventListener("click", function () { activeFilter = entry[1]; - [filterAll, filterFail, filterSkip].forEach(function (button) { + [filterAll, filterFail, filterWarn, filterInfo, filterSkip, filterManual].forEach(function (button) { button.dataset.active = button === entry[0] ? "1" : "0"; }); Object.keys(suiteNodes).forEach(function (name) { @@ -896,25 +1262,57 @@ }); function recount() { - setIconLabel(chipPass, "check", "通过 " + state.pass, 11); - setIconLabel(chipFail, "x", "失败 " + state.fail, 11); - setIconLabel(chipSkip, "minus", "跳过 " + state.skip, 11); + setIconLabel(chipPass, "check", "PASS " + state.pass, 11); + setIconLabel(chipFail, "x", "FAIL " + state.fail, 11); + setIconLabel(chipWarn, "info", "WARN " + state.warn, 11); + setIconLabel(chipInfo, "info", "INFO " + state.info, 11); + setIconLabel(chipSkip, "minus", "SKIP " + state.skip, 11); + setIconLabel(chipManual, "hand", "MANUAL " + state.manual, 11); setIconLabel(chipTotal, "hash", "共 " + state.total, 11); var total = state.total || 1; barPass.style.width = (state.pass / total) * 100 + "%"; barFail.style.width = (state.fail / total) * 100 + "%"; + barWarn.style.width = (state.warn / total) * 100 + "%"; + barInfo.style.width = (state.info / total) * 100 + "%"; barSkip.style.width = (state.skip / total) * 100 + "%"; - statusPill.className = "sc-status " + (state.fail ? "sc-status-fail" : "sc-status-pass"); + barManual.style.width = (state.manual / total) * 100 + "%"; + statusPill.className = + "sc-status " + (state.fail ? "sc-status-fail" : state.warn ? "sc-status-warn" : "sc-status-pass"); setIconLabel( statusPill, - state.fail ? "circle-x" : "check", - state.fail ? state.fail + " 项失败" : state.total && !state.skip ? "全部通过" : "运行中", + state.fail ? "circle-x" : state.warn ? "info" : "check", + state.fail + ? state.fail + " 项失败" + : state.warn + ? state.warn + " 项警告" + : state.total && !state.skip && !state.manual + ? "全部通过" + : "运行中", 13 ); duration.textContent = state.durationMs + "ms"; setIconLabel(queueChip, "list-todo", "待跑 " + state.skip, 11); sumLine.textContent = - "总测试数: " + state.total + " 通过: " + state.pass + " 失败: " + state.fail + " 跳过: " + state.skip; + "总测试数: " + + state.total + + " PASS: " + + state.pass + + " 通过: " + + state.pass + + " FAIL: " + + state.fail + + " 失败: " + + state.fail + + " WARN: " + + state.warn + + " INFO: " + + state.info + + " SKIP: " + + state.skip + + " 跳过: " + + state.skip + + " MANUAL: " + + state.manual; Object.keys(suiteNodes).forEach(function (name) { var suiteNode = suiteNodes[name]; var passed = 0; @@ -924,14 +1322,14 @@ var node = caseNodes[key]; if (node.suite !== name) return; suiteTotal++; - if (node.status === "pass") passed++; - if (node.status === "fail") failed++; + if (node.status === STATUS.PASS) passed++; + if (node.status === STATUS.FAIL) failed++; }); if (suiteNode.manualTotal) { - var manualDone = Object.keys(state.manualOverrides).filter(function (key) { - return key.indexOf(name + "//") === 0; + var manualPending = Object.keys(caseNodes).filter(function (key) { + return caseNodes[key].suite === name && caseNodes[key].status === STATUS.MANUAL; }).length; - setIconLabel(suiteNode.stat, "hand", "人工 " + manualDone + " / " + suiteNode.manualTotal, 10); + setIconLabel(suiteNode.stat, "hand", "人工待确认 " + manualPending + " / " + suiteNode.manualTotal, 10); } else { suiteNode.stat.textContent = passed + " / " + suiteTotal; suiteNode.stat.dataset.failed = failed ? "1" : "0"; @@ -973,15 +1371,31 @@ chevron.textContent = ""; chevron.appendChild(icon(group.hidden ? "chevron-right" : "chevron-down", 13)); }); - suiteNodes[name] = { row: row, group: group, stat: stat, chevron: chevron, manualTotal: manualTotal, collapsed: false }; + suiteNodes[name] = { + row: row, + group: group, + stat: stat, + chevron: chevron, + manualTotal: manualTotal, + collapsed: false, + }; return suiteNodes[name]; } function applyStatus(c, node) { - var statusIcon = c.status === "pass" ? "check" : c.status === "fail" ? "x" : c.status === "manual" ? "hand" : "minus"; + var statusIcon = + c.status === STATUS.PASS + ? "check" + : c.status === STATUS.FAIL + ? "x" + : c.status === STATUS.MANUAL + ? "hand" + : "info"; node.icon.textContent = ""; node.icon.appendChild(icon(statusIcon, 13)); - node.dur.textContent = c.status === "manual" ? "人工" : c.durationMs + "ms"; + node.statusLabel.textContent = c.status; + node.statusLabel.className = "sc-case-status sc-status-" + c.status.toLowerCase(); + node.dur.textContent = c.status === STATUS.MANUAL ? "人工" : c.durationMs + "ms"; } // 渲染/清理失败详情框。挂在 node.detail 上以便重跑时能先移除旧的一份, @@ -992,20 +1406,31 @@ node.detail.remove(); node.detail = null; } - if (c.status === "fail") { + if ( + c.status === STATUS.FAIL || + c.status === STATUS.WARN || + c.status === STATUS.INFO || + c.status === STATUS.SKIP + ) { var detail = el( "div", "sc-detail", - "期望 " + (c.expected == null ? "-" : c.expected) + "\n实际 " + (c.actual == null ? "-" : c.actual) + "\n" + c.error + "状态 " + + c.status + + "\n期望 " + + (c.expected == null ? "-" : c.expected) + + "\n实际 " + + (c.actual == null ? "-" : c.actual) + + "\n" + + (c.detail || "") + + (c.error ? "\n" + c.error : "") + ); + detail.setAttribute( + "data-sctest", + c.status === STATUS.FAIL ? "failure-detail" : c.status === STATUS.SKIP ? "skip-reason" : "diagnostic-detail" ); - detail.setAttribute("data-sctest", "failure-detail"); node.row.parentNode.insertBefore(detail, node.row.nextSibling); node.detail = detail; - } else if (c.status === "skip" && c.error) { - var reason = el("div", "sc-detail", c.error); - reason.setAttribute("data-sctest", "skip-reason"); - node.row.parentNode.insertBefore(reason, node.row.nextSibling); - node.detail = reason; } } @@ -1021,41 +1446,68 @@ var key = c.suite + "//" + c.name; var existing = caseNodes[key]; if (existing) { - if (existing.status === "skip") state.skip--; - else if (existing.status === "pass") state.pass--; - else if (existing.status === "fail") state.fail--; + if (existing.status === STATUS.PASS) state.pass--; + else if (existing.status === STATUS.FAIL) state.fail--; + else if (existing.status === STATUS.WARN) state.warn--; + else if (existing.status === STATUS.INFO) state.info--; + else if (existing.status === STATUS.SKIP) state.skip--; + else if (existing.status === STATUS.MANUAL) state.manual--; existing.status = c.status; + existing.result = c; applyStatus(c, existing); renderDetail(existing, c); - if (c.status === "pass") state.pass++; - else if (c.status === "fail") state.fail++; - else state.skip++; + if (c.status === STATUS.PASS) state.pass++; + else if (c.status === STATUS.FAIL) state.fail++; + else if (c.status === STATUS.WARN) state.warn++; + else if (c.status === STATUS.INFO) state.info++; + else if (c.status === STATUS.SKIP) state.skip++; + else if (c.status === STATUS.MANUAL) state.manual++; recount(); return; } var suite = ensureSuite(c.suite); - var row = el("div", "sc-case" + (c.status === "manual" ? " sc-case-manual" : "")); + var row = el("div", "sc-case" + (c.status === STATUS.MANUAL ? " sc-case-manual" : "")); row.setAttribute("data-sctest", "case-row"); var caseIcon = el("b", null, ICONS[c.status] || "○"); - var label = el("span", null, c.name); - var dur = el("i", "sc-dur", c.status === "manual" ? "人工" : c.durationMs + "ms"); + var label = el("span", "sc-case-label"); + label.appendChild(el("span", null, c.name)); + label.appendChild(el("small", "sc-case-category", c.category || c.suite)); + var statusLabel = el("strong", "sc-case-status", c.status); + var dur = el("i", "sc-dur", c.status === STATUS.MANUAL ? "人工" : c.durationMs + "ms"); row.appendChild(caseIcon); row.appendChild(label); + row.appendChild(statusLabel); row.appendChild(dur); suite.group.appendChild(row); - var node = { row: row, icon: caseIcon, dur: dur, status: c.status, suite: c.suite, detail: null, hint: null }; + var node = { + row: row, + icon: caseIcon, + statusLabel: statusLabel, + dur: dur, + status: c.status, + suite: c.suite, + result: c, + detail: null, + hint: null, + }; caseNodes[key] = node; - if (c.status === "fail") { + if (c.status === STATUS.FAIL) { state.fail++; - } else if (c.status === "pass") { + } else if (c.status === STATUS.PASS) { state.pass++; - } else { + } else if (c.status === STATUS.WARN) { + state.warn++; + } else if (c.status === STATUS.INFO) { + state.info++; + } else if (c.status === STATUS.SKIP) { state.skip++; + } else if (c.status === STATUS.MANUAL) { + state.manual++; } renderDetail(node, c); - if (c.status === "manual") { + if (c.status === STATUS.MANUAL) { var pass = el("button", "sc-btn sc-icon-btn sc-manual-pass"); pass.appendChild(icon("check", 12)); pass.setAttribute("data-sctest", "manual-pass"); @@ -1063,17 +1515,24 @@ fail.appendChild(icon("x", 12)); fail.setAttribute("data-sctest", "manual-fail"); function settle(ok) { - state.skip--; - if (ok) state.pass++; - else state.fail++; - state.manualOverrides[c.suite + "//" + c.name] = ok ? "pass" : "fail"; - node.status = ok ? "pass" : "fail"; row.classList.remove("sc-case-manual"); - caseIcon.textContent = ""; - caseIcon.appendChild(icon(ok ? "check" : "x", 13)); pass.remove(); fail.remove(); - recount(); + if (typeof runInfo.onManualVerdict === "function") { + runInfo.onManualVerdict( + c.suite, + c.name, + ok ? STATUS.PASS : STATUS.FAIL, + ok ? "人工确认通过" : "人工确认失败" + ); + } else { + node.status = ok ? STATUS.PASS : STATUS.FAIL; + c.status = node.status; + c.manualVerdict = node.status; + c.detail = ok ? "人工确认通过" : "人工确认失败"; + applyStatus(c, node); + recount(); + } } pass.addEventListener("click", function () { settle(true); @@ -1135,22 +1594,24 @@ emitLog("▶ " + info.name, "info", { sctest: "run", context: info.context, cases: cases }); }, onCase: function (c) { - if (c.status === STATUS.PASS) { - emitLog("✓ " + c.suite + " › " + c.name, "info", { + var status = normalizeStatus(c.status); + if (status === STATUS.PASS) { + emitLog("✓ [PASS] " + c.suite + " › " + c.name + formatDetails(c), "info", { sctest: "case", - status: "pass", + status: status, ms: c.durationMs, }); - } else if (c.status === STATUS.FAIL) { - emitLog("✗ " + c.suite + " › " + c.name + " — " + c.error, "error", { + } else if (status === STATUS.FAIL) { + emitLog("✗ [FAIL] " + c.suite + " › " + c.name + formatDetails(c), "error", { sctest: "case", - status: "fail", + status: status, suite: c.suite, }); } else { - emitLog("○ " + c.suite + " › " + c.name + (c.error ? " — " + c.error : ""), "warn", { + var level = status === STATUS.INFO ? "info" : status === STATUS.WARN ? "warn" : "warn"; + emitLog("○ [" + status + "] " + c.suite + " › " + c.name + formatDetails(c), level, { sctest: "case", - status: "skip", + status: status, }); } }, @@ -1158,34 +1619,51 @@ emitLog( "■ 总测试数: " + summary.total + - " 通过: " + + " PASS: " + summary.passed + - " 失败: " + + " FAIL: " + summary.failed + - " 跳过: " + + " WARN: " + + summary.warned + + " INFO: " + + summary.info + + " SKIP: " + summary.skipped + + " MANUAL: " + + summary.manual + " (" + summary.durationMs + "ms)", "info", - { sctest: "summary", passed: summary.passed, failed: summary.failed } + { sctest: "summary", passed: summary.passed, failed: summary.failed, status: summary.overall } ); }, }; } + function formatDetails(c) { + var details = []; + if (c.error) details.push("error=" + c.error); + if (c.expected != null) details.push("expected=" + stringify(c.expected)); + if (c.actual != null) details.push("actual=" + stringify(c.actual)); + if (c.detail) details.push("detail=" + c.detail); + return details.length ? " — " + details.join("; ") : ""; + } + var api = { create: create, + createReportSession: createReportSession, skip: function (reason) { throw new SkipSignal(reason); }, + STATUS: STATUS, + MARKER: SCTEST_MARKER, __detectContext: detectContext, __buildReporters: buildReporters, __createConsoleReporter: createConsoleReporter, __createPanelReporter: createPanelReporter, __createLogReporter: createLogReporter, __installPanelStyles: installPanelStyles, - STATUS: STATUS, }; global.SCTest = api; diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index 4fac92cbe..93c23e145 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -138,15 +138,27 @@ vdescribe("sctest 框架内核", () => { vit("toThrow 要求被测目标是函数并且确实抛异常", () => { const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); - vexpect(() => e(() => { throw new Error("boom"); }).toThrow()).not.toThrow(); + vexpect(() => + e(() => { + throw new Error("boom"); + }).toThrow() + ).not.toThrow(); vexpect(() => e(() => {}).toThrow()).toThrow(); - vexpect(() => e(() => { throw new Error("boom"); }).toThrow(/boom/)).not.toThrow(); + vexpect(() => + e(() => { + throw new Error("boom"); + }).toThrow(/boom/) + ).not.toThrow(); }); vit("toThrow 接受抛出 falsy 值的函数", () => { const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); [0, false, null, undefined].forEach((value) => { - vexpect(() => e(() => { throw value; }).toThrow()).not.toThrow(); + vexpect(() => + e(() => { + throw value; + }).toThrow() + ).not.toThrow(); }); }); @@ -160,7 +172,13 @@ vdescribe("sctest 框架内核", () => { vdescribe("运行核心", () => { vit("统计通过/失败/跳过并按 suite 分组", async () => { - const { describe: d, it: i, itManual: im, expect: e, run } = SCTest.create({ + const { + describe: d, + it: i, + itManual: im, + expect: e, + run, + } = SCTest.create({ name: "demo", reporter: "console", }); @@ -181,17 +199,20 @@ vdescribe("sctest 框架内核", () => { vexpect(summary.total).toBe(4); vexpect(summary.passed).toBe(2); vexpect(summary.failed).toBe(1); - vexpect(summary.skipped).toBe(1); + vexpect(summary.skipped).toBe(0); + vexpect(summary.manual).toBe(1); vexpect(summary.suites.map((s) => s.name)).toEqual(["组一", "组二"]); - vexpect(summary.suites[0].cases[1].status).toBe("fail"); + vexpect(summary.suites[0].cases[1].status).toBe("FAIL"); vexpect(summary.suites[0].cases[1].error).toMatch(/期望 2/); - vexpect(summary.suites[1].cases[1].status).toBe("manual"); + vexpect(summary.suites[1].cases[1].status).toBe("MANUAL"); }); vit("一个用例抛异常不影响后续用例执行", async () => { const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "console" }); d("组", () => { - i("炸", () => { throw new Error("boom"); }); + i("炸", () => { + throw new Error("boom"); + }); i("仍然跑", () => e(1).toBe(1)); }); const summary = await run(); @@ -247,7 +268,8 @@ vdescribe("sctest 框架内核", () => { console.log = orig; } const text = lines.join("\n"); - vexpect(text).toMatch(/○ 需要人工点击 \(待人工确认:点一下确认按钮\)/); + vexpect(text).toMatch(/✋ \[MANUAL\] 需要人工点击/); + vexpect(text).toMatch(/点一下确认按钮/); }); vit("人工用例没有 hint 时,沿用原有 (待人工确认) 措辞", async () => { @@ -264,7 +286,7 @@ vdescribe("sctest 框架内核", () => { console.log = orig; } const text = lines.join("\n"); - vexpect(text).toMatch(/○ 无提示的人工用例 \(待人工确认\)/); + vexpect(text).toMatch(/✋ \[MANUAL\] 无提示的人工用例/); }); }); @@ -279,7 +301,7 @@ vdescribe("sctest 框架内核", () => { vexpect(summary.skipped).toBe(1); vexpect(summary.failed).toBe(0); - vexpect(summary.suites[0].cases[0].status).toBe("skip"); + vexpect(summary.suites[0].cases[0].status).toBe("SKIP"); vexpect(summary.suites[0].cases[0].error).toBe("没有可用的下载目录"); }); @@ -323,7 +345,7 @@ vdescribe("sctest 框架内核", () => { } finally { console.log = orig; } - vexpect(lines.join("\n")).toMatch(/○ 条件不满足 \(跳过: 需要人工先授权\)/); + vexpect(lines.join("\n")).toMatch(/○ \[SKIP\] 条件不满足.*需要人工先授权/); }); vit("LogReporter 把跳过原因写进日志正文", async () => { @@ -335,8 +357,125 @@ vdescribe("sctest 框架内核", () => { } finally { delete globalThis.GM_log; } - vexpect(logged[0].msg).toMatch(/○ 组 › 条件不满足 — 需要人工先授权/); - vexpect(logged[0].labels.status).toBe("skip"); + vexpect(logged[0].msg).toMatch(/○ \[SKIP\] 组 › 条件不满足.*error=需要人工先授权/); + vexpect(logged[0].labels.status).toBe("SKIP"); + }); + }); + + vdescribe("诊断式 check/note 协议", () => { + vit("支持异步 predicate、WARN/INFO/SKIP 与 required 字段", async () => { + const { describe: d, check, note, run } = SCTest.create({ name: "diagnostics", reporter: "console" }); + d("诊断", () => { + check( + "能力", + "异步通过", + async () => { + await Promise.resolve(); + return true; + }, + "可用", + "可用", + "异步能力已响应" + ); + check("能力", "可选能力缺失", () => false, "可用", "缺失", "可选 API 未提供", { + onFail: "WARN", + required: false, + }); + check( + "能力", + "检测异常", + () => { + throw new Error("probe failed"); + }, + "不抛异常", + "抛出异常", + "可选探针异常", + { onError: "WARN", required: false } + ); + note("环境", "运行环境", "浏览器页面", "happy-dom", "记录环境,不作自动断言"); + check("环境", "暂不适用", () => SCTest.skip("没有对应 API"), "可用", "当前环境未提供", "环境不支持"); + }); + + const summary = await run(); + + vexpect(summary.counts).toEqual({ PASS: 1, FAIL: 0, WARN: 2, INFO: 1, SKIP: 1, MANUAL: 0 }); + vexpect(summary.warned).toBe(2); + vexpect(summary.info).toBe(1); + vexpect(summary.overall).toBe("WARN"); + vexpect(summary.suites[0].cases.map((item) => item.category)).toEqual(["能力", "能力", "能力", "环境", "环境"]); + vexpect(summary.suites[0].cases[1].required).toBe(false); + }); + + vit("createReportSession 可记录并更新人工结果,最终汇总使用同一条记录", () => { + const session = SCTest.createReportSession({ name: "session", reporter: "console" }); + const pending = session.manual("操作", "点击菜单", "用户看到菜单", "等待操作", "需要人工确认"); + session.update(pending, "PASS", { actual: "用户看到菜单", detail: "人工确认完成", manualVerdict: "PASS" }); + session.note("环境", "版本", "浏览器", "测试环境", "只记录环境信息"); + + const summary = session.finish(); + + vexpect(summary.manual).toBe(0); + vexpect(summary.passed).toBe(1); + vexpect(summary.info).toBe(1); + vexpect(summary.suites[0].cases[0].manualVerdict).toBe("PASS"); + }); + + vit("check 兼容不返回值的旧断言体", async () => { + const { describe: d, check, run } = SCTest.create({ name: "legacy check", reporter: "console" }); + d("兼容", () => { + check( + "自动断言", + "只抛异常表示失败", + () => { + // 旧 it 用例的断言通过后没有显式 return。 + }, + null, + null, + "保留旧断言体语义" + ); + }); + + const summary = await run(); + + vexpect(summary.counts).toEqual({ PASS: 1, FAIL: 0, WARN: 0, INFO: 0, SKIP: 0, MANUAL: 0 }); + }); + + vit("报告 session 完成后更新结果会重新发出 JSON 汇总", () => { + const ended = []; + const originalBuildReporters = SCTest.__buildReporters; + SCTest.__buildReporters = () => [{ onEnd: (summary) => ended.push(summary) }]; + try { + const session = SCTest.createReportSession({ name: "late update" }); + const pending = session.manual("操作", "点击", "完成", "等待", "需要人工确认"); + session.finish(); + session.update(pending, "PASS", { manualVerdict: "PASS" }); + + vexpect(ended.length).toBe(2); + vexpect(ended.at(-1).counts).toEqual({ PASS: 1, FAIL: 0, WARN: 0, INFO: 0, SKIP: 0, MANUAL: 0 }); + } finally { + SCTest.__buildReporters = originalBuildReporters; + } + }); + + vit("人工裁决事件会把 MANUAL 同步成最终 PASS/FAIL", async () => { + let runInfo; + const cases = []; + const ends = []; + SCTest.__buildReporters = (opts, context, info) => { + runInfo = info; + return [{ onCase: (result) => cases.push(result), onEnd: (summary) => ends.push(summary) }]; + }; + const { describe: d, itManual, run } = SCTest.create({ name: "manual", reporter: "console" }); + d("操作", () => itManual("打开菜单", { hint: "从扩展菜单点击" })); + + const first = await run(); + const final = await runInfo.onManualVerdict("操作", "打开菜单", "PASS", "人工已确认"); + + vexpect(first.manual).toBe(1); + vexpect(cases.map((item) => item.status)).toEqual(["MANUAL", "PASS"]); + vexpect(final.manual).toBe(0); + vexpect(final.passed).toBe(1); + vexpect(ends.at(-1).passed).toBe(1); }); }); }); @@ -344,10 +483,7 @@ vdescribe("sctest 框架内核", () => { vdescribe("sctest 用户脚本引用", () => { vit("固定到包含当前框架版本的提交", () => { const testsDir = resolve(import.meta.dirname, ".."); - const consumers = [ - ...readdirSync(testsDir).filter((name) => name.endsWith(".js")), - "lib/README.md", - ]; + const consumers = [...readdirSync(testsDir).filter((name) => name.endsWith(".js")), "lib/README.md"]; for (const name of consumers) { const source = readFileSync(resolve(testsDir, name), "utf8"); @@ -432,7 +568,8 @@ vdescribe("PanelReporter", () => { const { describe: d, itManual: im, run } = SCTest.create({ name: "demo", reporter: "panel" }); d("组", () => im("点一下菜单", { hint: "打开扩展菜单" })); const summary = await run(); - vexpect(summary.skipped).toBe(1); + vexpect(summary.skipped).toBe(0); + vexpect(summary.manual).toBe(1); const root = document.getElementById("sctest-panel-host").shadowRoot; root.querySelector('[data-sctest="manual-pass"]').click(); @@ -554,13 +691,13 @@ vdescribe("PanelReporter", () => { vexpect(visibleCases()).toHaveLength(3); }); - vit("跳过筛选包含待人工确认用例", async () => { + vit("人工筛选只显示待人工确认用例", async () => { const { describe: d, itManual: im, run } = SCTest.create({ name: "demo", reporter: "panel" }); d("人工组", () => im("待确认")); await run(); const root = document.getElementById("sctest-panel-host").shadowRoot; - root.querySelector('[data-sctest="filter-skip"]').click(); + root.querySelector('[data-sctest="filter-manual"]').click(); vexpect(root.querySelector('[data-sctest="case-row"]').hidden).toBe(false); }); @@ -593,7 +730,7 @@ vdescribe("PanelReporter", () => { vexpect(root.querySelector('[data-sctest="case-row"]').hidden).toBe(true); vexpect(root.querySelector(".sc-hint").hidden).toBe(true); root.querySelector('[data-sctest="export-json"]').click(); - vexpect(JSON.parse(writeText.mock.calls[0][0]).cases[0].status).toBe("pass"); + vexpect(JSON.parse(writeText.mock.calls[0][0]).cases[0].status).toBe("PASS"); }); vit("单组和全部收缩都能再次点击展开", async () => { @@ -634,9 +771,9 @@ vdescribe("PanelReporter", () => { const root = document.getElementById("sctest-panel-host").shadowRoot; const panel = root.querySelector(".sc-panel"); - root.querySelector('[data-sctest="drag-handle"]').dispatchEvent( - new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 10, clientY: 20 }) - ); + root + .querySelector('[data-sctest="drag-handle"]') + .dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: 10, clientY: 20 })); document.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: 40, clientY: 60 })); document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); vexpect(panel.style.left).toBe("30px"); @@ -655,7 +792,12 @@ vdescribe("LogReporter", () => { }); vit("开始日志的 level 与 label 符合约定,cases 计入注册的用例总数", async () => { - const { describe: d, it: i, expect: e, run } = SCTest.create({ + const { + describe: d, + it: i, + expect: e, + run, + } = SCTest.create({ name: "demo", reporter: "log", context: "background", @@ -687,12 +829,12 @@ vdescribe("LogReporter", () => { const cases = calls.filter((c) => c.labels && c.labels.sctest === "case"); vexpect(cases.length).toBe(2); vexpect(cases[0].level).toBe("info"); - vexpect(cases[0].labels.status).toBe("pass"); + vexpect(cases[0].labels.status).toBe("PASS"); vexpect(cases[1].level).toBe("error"); - vexpect(cases[1].labels.status).toBe("fail"); + vexpect(cases[1].labels.status).toBe("FAIL"); }); - vit("跳过/人工用例发出 warn 级别日志,label 标记 status: skip", async () => { + vit("跳过/人工用例发出 warn 级别日志,label 保留独立 MANUAL 状态", async () => { const { describe: d, itManual: im, run } = SCTest.create({ name: "demo", reporter: "log" }); d("组", () => im("待人工确认的用例")); await run(); @@ -700,7 +842,7 @@ vdescribe("LogReporter", () => { const cases = calls.filter((c) => c.labels && c.labels.sctest === "case"); vexpect(cases.length).toBe(1); vexpect(cases[0].level).toBe("warn"); - vexpect(cases[0].labels.status).toBe("skip"); + vexpect(cases[0].labels.status).toBe("MANUAL"); }); vit("汇总是单条日志,label 带 passed/failed", async () => { @@ -731,7 +873,12 @@ vdescribe("LogReporter", () => { }); vit("auto 模式下 @crontab 脚本选用 LogReporter", async () => { - const { describe: d, it: i, expect: e, run } = SCTest.create({ + const { + describe: d, + it: i, + expect: e, + run, + } = SCTest.create({ name: "demo", context: "crontab", }); @@ -813,7 +960,7 @@ vdescribe("手动 suite 触发", () => { await runInfo.onRerun(); const result = results.at(-1); - vexpect(result.status).toBe("pass"); + vexpect(result.status).toBe("PASS"); vexpect(result.error).toBe(null); vexpect(result.expected).toBe(null); vexpect(result.actual).toBe(null); diff --git a/example/tests/sandbox_test.js b/example/tests/sandbox_test.js index ba29d5ad8..6480a589c 100644 --- a/example/tests/sandbox_test.js +++ b/example/tests/sandbox_test.js @@ -31,7 +31,7 @@ mpt.name = "test-element-1002"; (async function () { "use strict"; - const { describe, it, expect, run } = SCTest.create({ name: "半沙盒环境测试" }); + const { describe, check, expect, run } = SCTest.create({ name: "半沙盒环境测试" }); const markerPrefix = `__scriptcat_sandbox_${Date.now()}_${Math.random().toString(36).slice(2)}`; @@ -40,8 +40,7 @@ mpt.name = "test-element-1002"; if (value === unsafeWindow) return "[unsafeWindow]"; if (value === document) return "[document]"; if (value && value.nodeType) return `[node ${value.nodeName}]`; - if (typeof value === "function") - return `[function ${value.name || "anonymous"}]`; + if (typeof value === "function") return `[function ${value.name || "anonymous"}]`; if (typeof value === "symbol") return value.toString(); try { return JSON.stringify(value); @@ -54,9 +53,7 @@ mpt.name = "test-element-1002"; // 其余断言点已迁移为 expect(actual).toBe(expected)。 function assertSame(expected, actual, message) { if (!Object.is(expected, actual)) { - throw new Error( - `${message} - 期望 ${formatValue(expected)}, 实际 ${formatValue(actual)}`, - ); + throw new Error(`${message} - 期望 ${formatValue(expected)}, 实际 ${formatValue(actual)}`); } } @@ -73,11 +70,7 @@ mpt.name = "test-element-1002"; } catch { threw = true; } - assertSame( - expected, - read(), - `${message}${threw ? "(赋值抛出,值保持不变)" : ""}`, - ); + assertSame(expected, read(), `${message}${threw ? "(赋值抛出,值保持不变)" : ""}`); } function waitForEventLoop() { @@ -122,592 +115,804 @@ mpt.name = "test-element-1002"; } describe("沙盒全局身份", () => { - it("检测全局 testVar1001 会否跳出沙盒", () => { - expect(window["testVar1001"]).toBe(undefined); - expect(unsafeWindow["testVar1001"]).toBe(undefined); - }); - - it("检测全局 test-element-1002", () => { - expect(unsafeWindow["test-element-1002"]?.id).toBe("test-element-1002"); - expect(window["test-element-1002"]?.id).toBe(undefined); - }); + check( + "自动断言", + "检测全局 testVar1001 会否跳出沙盒", + () => { + expect(window["testVar1001"]).toBe(undefined); + expect(unsafeWindow["testVar1001"]).toBe(undefined); + }, + null, + null, + "保留原有断言体" + ); - it("window/self/globalThis/top/parent/frames 均指向沙盒对象", () => { - expect(typeof unsafeWindow).toBe("object"); - assertNotSame( - unsafeWindow, - window, - "默认 grant 环境下 window 不应是页面 window", - ); - expect(self).toBe(window); - expect(globalThis).toBe(window); - expect(top).toBe(window); - expect(parent).toBe(window); - expect(frames).toBe(window); - expect(Object.prototype.toString.call(window)).toBe("[object Window]"); - }); + check( + "自动断言", + "检测全局 test-element-1002", + () => { + expect(unsafeWindow["test-element-1002"]?.id).toBe("test-element-1002"); + expect(window["test-element-1002"]?.id).toBe(undefined); + }, + null, + null, + "保留原有断言体" + ); - it("沙盒 window 使用空原型,但保留页面 Window 外观 (Issue #962)", () => { - expect(Object.getPrototypeOf(window)).toBe(null); - expect(window.constructor).toBe(unsafeWindow.constructor); - expect(window.__proto__).toBe(unsafeWindow.__proto__); - expect(window instanceof unsafeWindow.constructor).toBe(false); - }); + check( + "自动断言", + "window/self/globalThis/top/parent/frames 均指向沙盒对象", + () => { + expect(typeof unsafeWindow).toBe("object"); + assertNotSame(unsafeWindow, window, "默认 grant 环境下 window 不应是页面 window"); + expect(self).toBe(window); + expect(globalThis).toBe(window); + expect(top).toBe(window); + expect(parent).toBe(window); + expect(frames).toBe(window); + expect(Object.prototype.toString.call(window)).toBe("[object Window]"); + }, + null, + null, + "保留原有断言体" + ); - it("页面 DOM getter 返回真实页面对象", () => { - expect(document).toBe(unsafeWindow.document); - expect(location.href).toBe(unsafeWindow.location.href); - expect(document.documentElement).toBe(unsafeWindow.document.documentElement); - }); + check( + "自动断言", + "沙盒 window 使用空原型,但保留页面 Window 外观 (Issue #962)", + () => { + expect(Object.getPrototypeOf(window)).toBe(null); + expect(window.constructor).toBe(unsafeWindow.constructor); + expect(window.__proto__).toBe(unsafeWindow.__proto__); + expect(window instanceof unsafeWindow.constructor).toBe(false); + }, + null, + null, + "保留原有断言体" + ); - it("页面全局变量不会自动穿透到沙盒 window", () => - withCleanup( - () => { - const key = `${markerPrefix}_page_global`; - unsafeWindow[key] = "page-value"; - expect(unsafeWindow[key]).toBe("page-value"); - expect(window[key]).toBe(undefined); - - window[key] = "sandbox-value"; - expect(window[key]).toBe("sandbox-value"); - expect(unsafeWindow[key]).toBe("page-value"); - }, - () => { - delete window[`${markerPrefix}_page_global`]; - delete unsafeWindow[`${markerPrefix}_page_global`]; - }, - )); + check( + "自动断言", + "页面 DOM getter 返回真实页面对象", + () => { + expect(document).toBe(unsafeWindow.document); + expect(location.href).toBe(unsafeWindow.location.href); + expect(document.documentElement).toBe(unsafeWindow.document.documentElement); + }, + null, + null, + "保留原有断言体" + ); - it("页面 DOM named property 不应穿透为沙盒全局变量 (Issue #273, #700)", () => - withCleanup( - () => { - const id = `${markerPrefix}_named_element`; - const div = document.createElement("div"); - div.id = id; - document.body.appendChild(div); + check( + "自动断言", + "页面全局变量不会自动穿透到沙盒 window", + () => + withCleanup( + () => { + const key = `${markerPrefix}_page_global`; + unsafeWindow[key] = "page-value"; + expect(unsafeWindow[key]).toBe("page-value"); + expect(window[key]).toBe(undefined); - expect(unsafeWindow[id]).toBe(div); - expect(window[id]).toBe(undefined); - }, - () => { - document.getElementById(`${markerPrefix}_named_element`)?.remove(); - }, - )); + window[key] = "sandbox-value"; + expect(window[key]).toBe("sandbox-value"); + expect(unsafeWindow[key]).toBe("page-value"); + }, + () => { + delete window[`${markerPrefix}_page_global`]; + delete unsafeWindow[`${markerPrefix}_page_global`]; + } + ), + null, + null, + "保留原有断言体" + ); - it("删除沙盒全局变量不应删除页面同名全局变量 (Issue #522)", () => - withCleanup( - () => { - const key = `${markerPrefix}_delete_page_global`; - unsafeWindow[key] = "page-value"; + check( + "自动断言", + "页面 DOM named property 不应穿透为沙盒全局变量 (Issue #273, #700)", + () => + withCleanup( + () => { + const id = `${markerPrefix}_named_element`; + const div = document.createElement("div"); + div.id = id; + document.body.appendChild(div); + + expect(unsafeWindow[id]).toBe(div); + expect(window[id]).toBe(undefined); + }, + () => { + document.getElementById(`${markerPrefix}_named_element`)?.remove(); + } + ), + null, + null, + "保留原有断言体" + ); - expect(window[key]).toBe(undefined); + check( + "自动断言", + "删除沙盒全局变量不应删除页面同名全局变量 (Issue #522)", + () => + withCleanup( + () => { + const key = `${markerPrefix}_delete_page_global`; + unsafeWindow[key] = "page-value"; - window[key] = "sandbox-value"; - expect(window[key]).toBe("sandbox-value"); - expect(unsafeWindow[key]).toBe("page-value"); + expect(window[key]).toBe(undefined); - delete window[key]; + window[key] = "sandbox-value"; + expect(window[key]).toBe("sandbox-value"); + expect(unsafeWindow[key]).toBe("page-value"); - expect(window[key]).toBe(undefined); - expect(unsafeWindow[key]).toBe("page-value"); - }, - () => { - window[`${markerPrefix}_delete_page_global`] = undefined; - delete window[`${markerPrefix}_delete_page_global`]; - delete unsafeWindow[`${markerPrefix}_delete_page_global`]; - }, - )); + delete window[key]; - it("裸 delete 页面全局变量不应删除沙盒同名全局变量", () => - withCleanup( - () => { - const key = `${markerPrefix}_delete_bare_page_global`; - unsafeWindow[key] = "page-value"; - window[key] = "sandbox-value"; - - expect(window[key]).toBe("sandbox-value"); - expect(unsafeWindow[key]).toBe("page-value"); - - try { - Function(`return delete ${key};`)(); // 半沙盒在页面执行 - } catch (e) { - console.error(e); - delete unsafeWindow[key]; // fallback + expect(window[key]).toBe(undefined); + expect(unsafeWindow[key]).toBe("page-value"); + }, + () => { + window[`${markerPrefix}_delete_page_global`] = undefined; + delete window[`${markerPrefix}_delete_page_global`]; + delete unsafeWindow[`${markerPrefix}_delete_page_global`]; } + ), + null, + null, + "保留原有断言体" + ); - expect(unsafeWindow[key]).toBe(undefined); - expect(window[key]).toBe("sandbox-value"); - }, - () => { - window[`${markerPrefix}_delete_bare_page_global`] = undefined; - delete window[`${markerPrefix}_delete_bare_page_global`]; - delete unsafeWindow[`${markerPrefix}_delete_bare_page_global`]; - }, - )); - - it("Object.prototype 污染不会穿透到沙盒 window", () => - withCleanup( - () => { - const key = `${markerPrefix}_polluted`; - Object.prototype[key] = "polluted-value"; - expect({}[key]).toBe("polluted-value"); - expect(window[key]).toBe(undefined); - expect(key in window).toBe(false); - }, - () => { - delete Object.prototype[`${markerPrefix}_polluted`]; - }, - )); - - it("特殊关键字与内部字段不从页面或 GM context 泄漏", () => - withCleanup( - () => { - for (const key of ["define", "module", "exports"]) { - const desc = Object.getOwnPropertyDescriptor(unsafeWindow, key); - if (!desc || desc.writable || desc.set) { - unsafeWindow[key] = `page-${key}`; + check( + "自动断言", + "裸 delete 页面全局变量不应删除沙盒同名全局变量", + () => + withCleanup( + () => { + const key = `${markerPrefix}_delete_bare_page_global`; + unsafeWindow[key] = "page-value"; + window[key] = "sandbox-value"; + + expect(window[key]).toBe("sandbox-value"); + expect(unsafeWindow[key]).toBe("page-value"); + + try { + Function(`return delete ${key};`)(); // 半沙盒在页面执行 + } catch (e) { + console.error(e); + delete unsafeWindow[key]; // fallback } + + expect(unsafeWindow[key]).toBe(undefined); + expect(window[key]).toBe("sandbox-value"); + }, + () => { + window[`${markerPrefix}_delete_bare_page_global`] = undefined; + delete window[`${markerPrefix}_delete_bare_page_global`]; + delete unsafeWindow[`${markerPrefix}_delete_bare_page_global`]; } + ), + null, + null, + "保留原有断言体" + ); - expect(window.define).toBe(undefined); - expect(window.module).toBe(undefined); - expect(window.exports).toBe(undefined); - - for (const key of [ - "runFlag", - "prefix", - "message", - "contentMsg", - "scriptRes", - "valueChangeListener", - "EE", - "context", - "grantSet", - "eventId", - "loadScriptResolve", - "loadScriptPromise", - "setInvalidContext", - "isInvalidContext", - ]) { + check( + "自动断言", + "Object.prototype 污染不会穿透到沙盒 window", + () => + withCleanup( + () => { + const key = `${markerPrefix}_polluted`; + Object.prototype[key] = "polluted-value"; + expect({}[key]).toBe("polluted-value"); expect(window[key]).toBe(undefined); + expect(key in window).toBe(false); + }, + () => { + delete Object.prototype[`${markerPrefix}_polluted`]; } - }, - (() => { - const snapshots = snapshotPageProps(["define", "module", "exports"]); - return () => restorePageProps(snapshots); - })(), - )); - - it("console 与页面 console 隔离,但方法可用", () => { - assertNotSame( - unsafeWindow.console, - console, - "沙盒 console 应与页面 console 不是同一个对象", - ); - expect(typeof console.log).toBe("function"); - expect(typeof console.error).toBe("function"); - }); + ), + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "特殊关键字与内部字段不从页面或 GM context 泄漏", + () => + withCleanup( + () => { + for (const key of ["define", "module", "exports"]) { + const desc = Object.getOwnPropertyDescriptor(unsafeWindow, key); + if (!desc || desc.writable || desc.set) { + unsafeWindow[key] = `page-${key}`; + } + } + + expect(window.define).toBe(undefined); + expect(window.module).toBe(undefined); + expect(window.exports).toBe(undefined); + + for (const key of [ + "runFlag", + "prefix", + "message", + "contentMsg", + "scriptRes", + "valueChangeListener", + "EE", + "context", + "grantSet", + "eventId", + "loadScriptResolve", + "loadScriptPromise", + "setInvalidContext", + "isInvalidContext", + ]) { + expect(window[key]).toBe(undefined); + } + }, + (() => { + const snapshots = snapshotPageProps(["define", "module", "exports"]); + return () => restorePageProps(snapshots); + })() + ), + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "console 与页面 console 隔离,但方法可用", + () => { + assertNotSame(unsafeWindow.console, console, "沙盒 console 应与页面 console 不是同一个对象"); + expect(typeof console.log).toBe("function"); + expect(typeof console.error).toBe("function"); + }, + null, + null, + "保留原有断言体" + ); }); describe("原生函数与事件代理", () => { - it("裸调用原生函数已绑定真实页面 window,避免 Illegal invocation (Issue #189)", async () => { - const rawSetTimeout = setTimeout; - const rawSetInterval = setInterval; - const rawClearInterval = clearInterval; - let called = false; - await new Promise((resolve) => { - rawSetTimeout(() => { - called = true; - resolve(); - }, 0); - }); - expect(called).toBe(true); - - let intervalCount = 0; - await new Promise((resolve) => { - const timer = rawSetInterval(() => { - intervalCount++; - rawClearInterval(timer); - resolve(); - }, 0); - }); - expect(intervalCount).toBe(1); - - const rawAddEventListener = addEventListener; - const rawRemoveEventListener = removeEventListener; - const eventName = `${markerPrefix}_bare_listener`; - let count = 0; - const handler = () => { - count++; - }; - rawAddEventListener(eventName, handler); - unsafeWindow.dispatchEvent(new Event(eventName)); - rawRemoveEventListener(eventName, handler); - expect(count).toBe(1); - - if (typeof fetch === "function") { - const rawFetch = fetch; - expect(typeof rawFetch).toBe("function"); - } - }); + check( + "自动断言", + "裸调用原生函数已绑定真实页面 window,避免 Illegal invocation (Issue #189)", + async () => { + const rawSetTimeout = setTimeout; + const rawSetInterval = setInterval; + const rawClearInterval = clearInterval; + let called = false; + await new Promise((resolve) => { + rawSetTimeout(() => { + called = true; + resolve(); + }, 0); + }); + expect(called).toBe(true); + + let intervalCount = 0; + await new Promise((resolve) => { + const timer = rawSetInterval(() => { + intervalCount++; + rawClearInterval(timer); + resolve(); + }, 0); + }); + expect(intervalCount).toBe(1); + + const rawAddEventListener = addEventListener; + const rawRemoveEventListener = removeEventListener; + const eventName = `${markerPrefix}_bare_listener`; + let count = 0; + const handler = () => { + count++; + }; + rawAddEventListener(eventName, handler); + unsafeWindow.dispatchEvent(new Event(eventName)); + rawRemoveEventListener(eventName, handler); + expect(count).toBe(1); + + if (typeof fetch === "function") { + const rawFetch = fetch; + expect(typeof rawFetch).toBe("function"); + } + }, + null, + null, + "保留原有断言体" + ); - it("取出 window.addEventListener 后调用不会 Illegal invocation (Issue #773)", () => - withCleanup( - () => { - const rawAddEventListener = window.addEventListener; - const rawRemoveEventListener = window.removeEventListener; - const eventName = `${markerPrefix}_window_listener`; - let count = 0; - const handler = () => { - count++; - }; - - rawAddEventListener(eventName, handler); - unsafeWindow.dispatchEvent(new Event(eventName)); - rawRemoveEventListener(eventName, handler); - - expect(count).toBe(1); - }, - () => {}, - )); - - it("被 Proxy 包装的原生函数仍可安全裸调用 (Issue #1030)", async () => { - const proxiedSetTimeout = new Proxy(setTimeout, {}); - let called = false; - await new Promise((resolve) => { - proxiedSetTimeout(() => { - called = true; - resolve(); - }, 0); - }); - - expect(called).toBe(true); - }); + check( + "自动断言", + "取出 window.addEventListener 后调用不会 Illegal invocation (Issue #773)", + () => + withCleanup( + () => { + const rawAddEventListener = window.addEventListener; + const rawRemoveEventListener = window.removeEventListener; + const eventName = `${markerPrefix}_window_listener`; + let count = 0; + const handler = () => { + count++; + }; + + rawAddEventListener(eventName, handler); + unsafeWindow.dispatchEvent(new Event(eventName)); + rawRemoveEventListener(eventName, handler); + + expect(count).toBe(1); + }, + () => {} + ), + null, + null, + "保留原有断言体" + ); - it("getter 返回页面 window 时会替换为沙盒 window (Issue #1427)", () => { - expect(self).toBe(window); - expect(parent).toBe(window); - expect(top).toBe(window); - expect(frames).toBe(window); - }); + check( + "自动断言", + "被 Proxy 包装的原生函数仍可安全裸调用 (Issue #1030)", + async () => { + const proxiedSetTimeout = new Proxy(setTimeout, {}); + let called = false; + await new Promise((resolve) => { + proxiedSetTimeout(() => { + called = true; + resolve(); + }, 0); + }); + + expect(called).toBe(true); + }, + null, + null, + "保留原有断言体" + ); - it("onxxx 函数赋值由页面事件触发,event.target 为 unsafeWindow", () => - withCleanup( - () => { - let count = 0; - let thisIsNotWindow = false; - let eventTargetIsUnsafeWindow = false; - const eventName = `${markerPrefix}_onresize_probe`; - - window.onresize = function (event) { - count++; - thisIsNotWindow = this !== unsafeWindow; - eventTargetIsUnsafeWindow = event.target === unsafeWindow; - expect(event.type).toBe("resize"); - }; - - unsafeWindow.dispatchEvent(new Event("resize")); - expect(count).toBe(1); - expect(thisIsNotWindow).toBe(true); - expect(eventTargetIsUnsafeWindow).toBe(true); - - window.onresize = null; - unsafeWindow.dispatchEvent(new Event("resize")); - unsafeWindow.dispatchEvent(new Event(eventName)); - expect(count).toBe(1); - }, - () => { - window.onresize = null; - }, - )); - - it("onxxx 普通对象只保存不注册监听,primitive 值应移除已注册的监听", () => - withCleanup( - async () => { - let handled = false; - const listenerObject = { - handleEvent() { + check( + "自动断言", + "getter 返回页面 window 时会替换为沙盒 window (Issue #1427)", + () => { + expect(self).toBe(window); + expect(parent).toBe(window); + expect(top).toBe(window); + expect(frames).toBe(window); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "onxxx 函数赋值由页面事件触发,event.target 为 unsafeWindow", + () => + withCleanup( + () => { + let count = 0; + let thisIsNotWindow = false; + let eventTargetIsUnsafeWindow = false; + const eventName = `${markerPrefix}_onresize_probe`; + + window.onresize = function (event) { + count++; + thisIsNotWindow = this !== unsafeWindow; + eventTargetIsUnsafeWindow = event.target === unsafeWindow; + expect(event.type).toBe("resize"); + }; + + unsafeWindow.dispatchEvent(new Event("resize")); + expect(count).toBe(1); + expect(thisIsNotWindow).toBe(true); + expect(eventTargetIsUnsafeWindow).toBe(true); + + window.onresize = null; + unsafeWindow.dispatchEvent(new Event("resize")); + unsafeWindow.dispatchEvent(new Event(eventName)); + expect(count).toBe(1); + }, + () => { + window.onresize = null; + } + ), + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "onxxx 普通对象只保存不注册监听,primitive 值应移除已注册的监听", + () => + withCleanup( + async () => { + let handled = false; + const listenerObject = { + handleEvent() { + handled = true; + }, + }; + window.onfocus = listenerObject; + expect(window.onfocus).toBe(listenerObject); + unsafeWindow.dispatchEvent(new Event("focus")); + await waitForEventLoop(); + expect(handled).toBe(false); + handled = false; + const func = function () { handled = true; - }, - }; - window.onfocus = listenerObject; - expect(window.onfocus).toBe(listenerObject); - unsafeWindow.dispatchEvent(new Event("focus")); - await waitForEventLoop(); - expect(handled).toBe(false); - handled = false; - const func = function () { handled = true }; - window.onfocus = func; - expect(window.onfocus).toBe(func); - unsafeWindow.dispatchEvent(new Event("focus")); - await waitForEventLoop(); - expect(handled).toBe(true); - handled = false; - window.onfocus = 123; - assertNotSame(func, window.onfocus, "primitive 对象时注册能被移除 (1)"); - unsafeWindow.dispatchEvent(new Event("focus")); - await waitForEventLoop(); - expect(handled).toBe(false); - }, - () => { - window.onfocus = null; - window.onblur = null; - }, - )); + }; + window.onfocus = func; + expect(window.onfocus).toBe(func); + unsafeWindow.dispatchEvent(new Event("focus")); + await waitForEventLoop(); + expect(handled).toBe(true); + handled = false; + window.onfocus = 123; + assertNotSame(func, window.onfocus, "primitive 对象时注册能被移除 (1)"); + unsafeWindow.dispatchEvent(new Event("focus")); + await waitForEventLoop(); + expect(handled).toBe(false); + }, + () => { + window.onfocus = null; + window.onblur = null; + } + ), + null, + null, + "保留原有断言体" + ); - it("onxxx 函数替换后只调用最新函数", () => - withCleanup( - () => { - let oldCount = 0; - let newCount = 0; - window.onhashchange = function () { - oldCount++; - }; - window.onhashchange = function () { - newCount++; - }; - - unsafeWindow.dispatchEvent(new Event("hashchange")); - expect(oldCount).toBe(0); - expect(newCount).toBe(1); - }, - () => { - window.onhashchange = null; - }, - )); + check( + "自动断言", + "onxxx 函数替换后只调用最新函数", + () => + withCleanup( + () => { + let oldCount = 0; + let newCount = 0; + window.onhashchange = function () { + oldCount++; + }; + window.onhashchange = function () { + newCount++; + }; + + unsafeWindow.dispatchEvent(new Event("hashchange")); + expect(oldCount).toBe(0); + expect(newCount).toBe(1); + }, + () => { + window.onhashchange = null; + } + ), + null, + null, + "保留原有断言体" + ); // 测试对象仅限于 window 和 top - it("window/top 不能被脚本改写", () => { - assertThrowsOrKeepsValue( - () => { - window.window = "bad"; - }, - () => window.window, - window, - "window 自引用应保持不变", - ); - assertThrowsOrKeepsValue( - () => { - window.top = "bad"; - }, - () => window.top, - window, - "top 自引用应保持不变", - ); - }); + check( + "自动断言", + "window/top 不能被脚本改写", + () => { + assertThrowsOrKeepsValue( + () => { + window.window = "bad"; + }, + () => window.window, + window, + "window 自引用应保持不变" + ); + assertThrowsOrKeepsValue( + () => { + window.top = "bad"; + }, + () => window.top, + window, + "top 自引用应保持不变" + ); + }, + null, + null, + "保留原有断言体" + ); - it("TM半沙盒:把祖先类别继承直接写在半沙盒上 (Issue #1462 PR #1463)", async () => { - const trueWindow = unsafeWindow; - const sandboxWindow = window; - expect(Object.hasOwn(trueWindow, "addEventListener")).toBe(false); - expect(Reflect.has(trueWindow, "addEventListener")).toBe(true); - expect(Object.hasOwn(sandboxWindow, "addEventListener")).toBe(true); - expect(Reflect.has(sandboxWindow, "addEventListener")).toBe(true); - }); + check( + "自动断言", + "TM半沙盒:把祖先类别继承直接写在半沙盒上 (Issue #1462 PR #1463)", + async () => { + const trueWindow = unsafeWindow; + const sandboxWindow = window; + expect(Object.hasOwn(trueWindow, "addEventListener")).toBe(false); + expect(Reflect.has(trueWindow, "addEventListener")).toBe(true); + expect(Object.hasOwn(sandboxWindow, "addEventListener")).toBe(true); + expect(Reflect.has(sandboxWindow, "addEventListener")).toBe(true); + }, + null, + null, + "保留原有断言体" + ); }); describe("GM API 注入与命名空间", () => { - it("GM_info、GM.info 与 unsafeWindow 正确暴露", () => { - expect(typeof GM_info).toBe("object"); - expect(typeof GM.info).toBe("object"); - expect(JSON.stringify(GM.info)).toBe(JSON.stringify(GM_info)); - expect(window.unsafeWindow).toBe(unsafeWindow); - expect(typeof GM_info.script).toBe("object"); - }); + check( + "自动断言", + "GM_info、GM.info 与 unsafeWindow 正确暴露", + () => { + expect(typeof GM_info).toBe("object"); + expect(typeof GM.info).toBe("object"); + expect(JSON.stringify(GM.info)).toBe(JSON.stringify(GM_info)); + expect(window.unsafeWindow).toBe(unsafeWindow); + expect(typeof GM_info.script).toBe("object"); + }, + null, + null, + "保留原有断言体" + ); - it("GM_ 与 GM.* 双命名空间由 grant 自动补齐", () => { - expect(typeof GM_getValue).toBe("function"); - expect(typeof GM.getValue).toBe("function"); - expect(typeof GM_setValue).toBe("function"); - expect(typeof GM.setValue).toBe("function"); - expect(typeof GM_deleteValue).toBe("function"); - expect(typeof GM.deleteValue).toBe("function"); - expect(typeof GM_listValues).toBe("function"); - expect(typeof GM.listValues).toBe("function"); - }); + check( + "自动断言", + "GM_ 与 GM.* 双命名空间由 grant 自动补齐", + () => { + expect(typeof GM_getValue).toBe("function"); + expect(typeof GM.getValue).toBe("function"); + expect(typeof GM_setValue).toBe("function"); + expect(typeof GM.setValue).toBe("function"); + expect(typeof GM_deleteValue).toBe("function"); + expect(typeof GM.deleteValue).toBe("function"); + expect(typeof GM_listValues).toBe("function"); + expect(typeof GM.listValues).toBe("function"); + }, + null, + null, + "保留原有断言体" + ); - it("GM_setValue/GM_getValue/GM_deleteValue 同步路径正常", () => - withCleanup( - () => { - const key = `${markerPrefix}_value`; - GM_setValue(key, { env: "sandbox", ok: true }); - const stored = GM_getValue(key); - expect(stored.env).toBe("sandbox"); - expect(stored.ok).toBe(true); - expect(GM_listValues().includes(key)).toBeTruthy(); - GM_deleteValue(key); - expect(GM_getValue(key, "fallback")).toBe("fallback"); - }, - () => { - GM_deleteValue(`${markerPrefix}_value`); - }, - )); - - it("GM.setValue/GM.getValue/GM.deleteValue Promise 路径正常", async () => - withCleanup( - async () => { - const key = `${markerPrefix}_async_value`; - await withTimeout(GM.setValue(key, "async-value"), "GM.setValue"); - expect(await withTimeout(GM.getValue(key), "GM.getValue")).toBe("async-value"); - expect( - (await withTimeout(GM.listValues(), "GM.listValues")).includes(key) - ).toBeTruthy(); - await withTimeout(GM.deleteValue(key), "GM.deleteValue"); - expect( - await withTimeout(GM.getValue(key, "fallback"), "GM.getValue fallback") - ).toBe("fallback"); - }, - () => { - GM_deleteValue(`${markerPrefix}_async_value`); - }, - )); - - it("GM_setValues/GM_getValues 以及 GM.getValues 依赖注入正常", () => - withCleanup( - async () => { - const keyA = `${markerPrefix}_multi_a`; - const keyB = `${markerPrefix}_multi_b`; - const keyMissing = `${markerPrefix}_multi_missing`; - - GM_setValues({ [keyA]: "A", [keyB]: { deep: 1 } }); - const picked = GM_getValues([keyA, keyB, keyMissing]); - expect(picked[keyA]).toBe("A"); - expect(picked[keyB].deep).toBe(1); - expect(Object.prototype.hasOwnProperty.call(picked, keyMissing)).toBe(false); - - const defaults = GM_getValues({ - [keyA]: "default-a", - [keyMissing]: "default-missing", - }); - expect(defaults[keyA]).toBe("A"); - expect(defaults[keyMissing]).toBe("default-missing"); - - const asyncPicked = await withTimeout( - GM.getValues({ [keyB]: null }), - "GM.getValues", - ); - expect(asyncPicked[keyB].deep).toBe(1); - }, - () => { - GM_deleteValue(`${markerPrefix}_multi_a`); - GM_deleteValue(`${markerPrefix}_multi_b`); - }, - )); - - it("GM_cookie grant 构建函数对象与多级命名空间", () => { - expect(typeof GM_cookie).toBe("function"); - expect(typeof GM_cookie.set).toBe("function"); - expect(typeof GM_cookie.list).toBe("function"); - expect(typeof GM_cookie.delete).toBe("function"); - expect(typeof GM.cookie.set).toBe("function"); - expect(typeof GM.cookie.list).toBe("function"); - expect(typeof GM.cookie.delete).toBe("function"); - }); + check( + "自动断言", + "GM_setValue/GM_getValue/GM_deleteValue 同步路径正常", + () => + withCleanup( + () => { + const key = `${markerPrefix}_value`; + GM_setValue(key, { env: "sandbox", ok: true }); + const stored = GM_getValue(key); + expect(stored.env).toBe("sandbox"); + expect(stored.ok).toBe(true); + expect(GM_listValues().includes(key)).toBeTruthy(); + GM_deleteValue(key); + expect(GM_getValue(key, "fallback")).toBe("fallback"); + }, + () => { + GM_deleteValue(`${markerPrefix}_value`); + } + ), + null, + null, + "保留原有断言体" + ); - it("GM_addStyle 与 GM.addStyle 都插入页面 document", async () => - withCleanup( - async () => { - const className = `${markerPrefix}_style`; - const style = GM_addStyle( - `.${className} { color: rgb(1, 2, 3) !important; }`, - ); - expect(style.tagName).toBe("STYLE"); - expect(style.ownerDocument).toBe(document); - - const asyncStyle = await withTimeout( - GM.addStyle( - `.${className}_async { color: rgb(4, 5, 6) !important; }`, - ), - "GM.addStyle", - ); - expect(asyncStyle.tagName).toBe("STYLE"); - expect(asyncStyle.ownerDocument).toBe(document); - - style.dataset.scriptcatSandboxTest = "sync"; - asyncStyle.dataset.scriptcatSandboxTest = "async"; - }, - () => { - document - .querySelectorAll("style[data-scriptcat-sandbox-test]") - .forEach((node) => node.remove()); - }, - )); - - it("GM_addElement 支持默认 parent、显式 parent、非字符串 property", async () => - withCleanup( - async () => { - const key = `${markerPrefix}_gm_script`; - const div = GM_addElement("div", { - id: `${markerPrefix}_div`, - textContent: "ScriptCat sandbox test", - hidden: true, - }); - expect(div.tagName).toBe("DIV"); - expect(div.ownerDocument).toBe(document); - expect(div.hidden).toBe(true); - - const child = await withTimeout( - GM.addElement(div, "span", { - textContent: "child", - }), - "GM.addElement", - ); - expect(child.tagName).toBe("SPAN"); - expect(child.parentNode).toBe(div); - - const script = GM_addElement("script", { - textContent: `window["${key}"] = "from-gm-add-element";`, - }); - expect(unsafeWindow[key]).toBe("from-gm-add-element"); - expect(window[key]).toBe(undefined); - script.remove(); - }, - () => { - document.getElementById(`${markerPrefix}_div`)?.remove(); - delete unsafeWindow[`${markerPrefix}_gm_script`]; - }, - )); + check( + "自动断言", + "GM.setValue/GM.getValue/GM.deleteValue Promise 路径正常", + async () => + withCleanup( + async () => { + const key = `${markerPrefix}_async_value`; + await withTimeout(GM.setValue(key, "async-value"), "GM.setValue"); + expect(await withTimeout(GM.getValue(key), "GM.getValue")).toBe("async-value"); + expect((await withTimeout(GM.listValues(), "GM.listValues")).includes(key)).toBeTruthy(); + await withTimeout(GM.deleteValue(key), "GM.deleteValue"); + expect(await withTimeout(GM.getValue(key, "fallback"), "GM.getValue fallback")).toBe("fallback"); + }, + () => { + GM_deleteValue(`${markerPrefix}_async_value`); + } + ), + null, + null, + "保留原有断言体" + ); - it("window.close/window.focus grant 暴露为沙盒 window 方法", () => { - expect(typeof window.close).toBe("function"); - expect(typeof window.focus).toBe("function"); - assertNotSame(unsafeWindow.close, window.close, "沙盒 close 应不是页面原始 close"); - assertNotSame(unsafeWindow.focus, window.focus, "沙盒 focus 应不是页面原始 focus"); - }); + check( + "自动断言", + "GM_setValues/GM_getValues 以及 GM.getValues 依赖注入正常", + () => + withCleanup( + async () => { + const keyA = `${markerPrefix}_multi_a`; + const keyB = `${markerPrefix}_multi_b`; + const keyMissing = `${markerPrefix}_multi_missing`; + + GM_setValues({ [keyA]: "A", [keyB]: { deep: 1 } }); + const picked = GM_getValues([keyA, keyB, keyMissing]); + expect(picked[keyA]).toBe("A"); + expect(picked[keyB].deep).toBe(1); + expect(Object.prototype.hasOwnProperty.call(picked, keyMissing)).toBe(false); + + const defaults = GM_getValues({ + [keyA]: "default-a", + [keyMissing]: "default-missing", + }); + expect(defaults[keyA]).toBe("A"); + expect(defaults[keyMissing]).toBe("default-missing"); + + const asyncPicked = await withTimeout(GM.getValues({ [keyB]: null }), "GM.getValues"); + expect(asyncPicked[keyB].deep).toBe(1); + }, + () => { + GM_deleteValue(`${markerPrefix}_multi_a`); + GM_deleteValue(`${markerPrefix}_multi_b`); + } + ), + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_cookie grant 构建函数对象与多级命名空间", + () => { + expect(typeof GM_cookie).toBe("function"); + expect(typeof GM_cookie.set).toBe("function"); + expect(typeof GM_cookie.list).toBe("function"); + expect(typeof GM_cookie.delete).toBe("function"); + expect(typeof GM.cookie.set).toBe("function"); + expect(typeof GM.cookie.list).toBe("function"); + expect(typeof GM.cookie.delete).toBe("function"); + }, + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_addStyle 与 GM.addStyle 都插入页面 document", + async () => + withCleanup( + async () => { + const className = `${markerPrefix}_style`; + const style = GM_addStyle(`.${className} { color: rgb(1, 2, 3) !important; }`); + expect(style.tagName).toBe("STYLE"); + expect(style.ownerDocument).toBe(document); + + const asyncStyle = await withTimeout( + GM.addStyle(`.${className}_async { color: rgb(4, 5, 6) !important; }`), + "GM.addStyle" + ); + expect(asyncStyle.tagName).toBe("STYLE"); + expect(asyncStyle.ownerDocument).toBe(document); + + style.dataset.scriptcatSandboxTest = "sync"; + asyncStyle.dataset.scriptcatSandboxTest = "async"; + }, + () => { + document.querySelectorAll("style[data-scriptcat-sandbox-test]").forEach((node) => node.remove()); + } + ), + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "GM_addElement 支持默认 parent、显式 parent、非字符串 property", + async () => + withCleanup( + async () => { + const key = `${markerPrefix}_gm_script`; + const div = GM_addElement("div", { + id: `${markerPrefix}_div`, + textContent: "ScriptCat sandbox test", + hidden: true, + }); + expect(div.tagName).toBe("DIV"); + expect(div.ownerDocument).toBe(document); + expect(div.hidden).toBe(true); + + const child = await withTimeout( + GM.addElement(div, "span", { + textContent: "child", + }), + "GM.addElement" + ); + expect(child.tagName).toBe("SPAN"); + expect(child.parentNode).toBe(div); + + const script = GM_addElement("script", { + textContent: `window["${key}"] = "from-gm-add-element";`, + }); + expect(unsafeWindow[key]).toBe("from-gm-add-element"); + expect(window[key]).toBe(undefined); + script.remove(); + }, + () => { + document.getElementById(`${markerPrefix}_div`)?.remove(); + delete unsafeWindow[`${markerPrefix}_gm_script`]; + } + ), + null, + null, + "保留原有断言体" + ); + + check( + "自动断言", + "window.close/window.focus grant 暴露为沙盒 window 方法", + () => { + expect(typeof window.close).toBe("function"); + expect(typeof window.focus).toBe("function"); + assertNotSame(unsafeWindow.close, window.close, "沙盒 close 应不是页面原始 close"); + assertNotSame(unsafeWindow.focus, window.focus, "沙盒 focus 应不是页面原始 focus"); + }, + null, + null, + "保留原有断言体" + ); }); describe("兼容行为", () => { - it("Object 静态方法与 RegExp 静态状态保持可用", () => { - expect(Object.isFrozen(Object.freeze({}))).toBe(true); - - const match = "abc123".match(/(\d+)/); - expect(match && match[1]).toBe("123"); - expect(RegExp.$1).toBe("123"); - }); + check( + "自动断言", + "Object 静态方法与 RegExp 静态状态保持可用", + () => { + expect(Object.isFrozen(Object.freeze({}))).toBe(true); + + const match = "abc123".match(/(\d+)/); + expect(match && match[1]).toBe("123"); + expect(RegExp.$1).toBe("123"); + }, + null, + null, + "保留原有断言体" + ); - it("Symbol 属性只写入当前沙盒,不影响页面 window", () => - withCleanup( - () => { - const symbolKey = Symbol(`${markerPrefix}_symbol`); - window[symbolKey] = "sandbox-symbol"; - expect(window[symbolKey]).toBe("sandbox-symbol"); - expect(unsafeWindow[symbolKey]).toBe(undefined); - }, - () => {}, - )); + check( + "自动断言", + "Symbol 属性只写入当前沙盒,不影响页面 window", + () => + withCleanup( + () => { + const symbolKey = Symbol(`${markerPrefix}_symbol`); + window[symbolKey] = "sandbox-symbol"; + expect(window[symbolKey]).toBe("sandbox-symbol"); + expect(unsafeWindow[symbolKey]).toBe(undefined); + }, + () => {} + ), + null, + null, + "保留原有断言体" + ); if (location.origin.includes("content-security-policy")) { // CSP 不测试 eval } else { // eval 不一定能通过 // 这跟沙盒无关。不应进行此测试 - it("eval 保持可用,并在当前沙盒内解析全局", () => { - const key = `${markerPrefix}_eval`; - eval(`window["${key}"] = "from-eval";`); - expect(window[key]).toBe("from-eval"); - expect(unsafeWindow[key]).toBe(undefined); - delete window[key]; - }); + check( + "自动断言", + "eval 保持可用,并在当前沙盒内解析全局", + () => { + const key = `${markerPrefix}_eval`; + eval(`window["${key}"] = "from-eval";`); + expect(window[key]).toBe("from-eval"); + expect(unsafeWindow[key]).toBe(undefined); + delete window[key]; + }, + null, + null, + "保留原有断言体" + ); } }); diff --git a/example/tests/unwrap_e2e_test.js b/example/tests/unwrap_e2e_test.js index 96f763840..d7550c808 100644 --- a/example/tests/unwrap_e2e_test.js +++ b/example/tests/unwrap_e2e_test.js @@ -15,20 +15,41 @@ var __unwrap_e2e_global_var = "unwrap_success"; (function () { "use strict"; - const { describe, it, expect, run } = SCTest.create({ name: "@unwrap E2E 测试" }); + const { describe, check, expect, run } = SCTest.create({ name: "@unwrap E2E 测试" }); describe("@unwrap 环境", () => { - it("GM 对象在 unwrap 模式下为 undefined", () => { - expect(typeof GM).toBe("undefined"); - }); + check( + "自动断言", + "GM 对象在 unwrap 模式下为 undefined", + () => { + expect(typeof GM).toBe("undefined"); + }, + null, + null, + "保留原有断言体" + ); - it("GM_setValue 在 unwrap 模式下为 undefined", () => { - expect(typeof GM_setValue).toBe("undefined"); - }); + check( + "自动断言", + "GM_setValue 在 unwrap 模式下为 undefined", + () => { + expect(typeof GM_setValue).toBe("undefined"); + }, + null, + null, + "保留原有断言体" + ); - it("全局变量可在页面作用域访问", () => { - expect(window.__unwrap_e2e_global_var).toBe("unwrap_success"); - }); + check( + "自动断言", + "全局变量可在页面作用域访问", + () => { + expect(window.__unwrap_e2e_global_var).toBe("unwrap_success"); + }, + null, + null, + "保留原有断言体" + ); }); run(); diff --git a/example/tests/unwrap_test.js b/example/tests/unwrap_test.js index 2c6e54ad8..d92978687 100644 --- a/example/tests/unwrap_test.js +++ b/example/tests/unwrap_test.js @@ -19,12 +19,19 @@ var test_global_injection = "success"; // User can access the variable "test_global_injection" directly in DevTools (function () { - const { describe, it, expect, run } = SCTest.create({ name: "@unwrap 测试" }); + const { describe, check, expect, run } = SCTest.create({ name: "@unwrap 测试" }); describe("@unwrap 环境", () => { - it("GM 不应暴露", () => expect(typeof GM).toBe("undefined")); - it("GM_setValue 不应暴露", () => expect(typeof GM_setValue).toBe("undefined")); - it("jQuery 应可用", () => expect(typeof jQuery).toBe("function")); + check("自动断言", "GM 不应暴露", () => expect(typeof GM).toBe("undefined"), null, null, "保留原有断言体"); + check( + "自动断言", + "GM_setValue 不应暴露", + () => expect(typeof GM_setValue).toBe("undefined"), + null, + null, + "保留原有断言体" + ); + check("自动断言", "jQuery 应可用", () => expect(typeof jQuery).toBe("function"), null, null, "保留原有断言体"); }); run(); diff --git a/example/tests/window_message_test.js b/example/tests/window_message_test.js index 56cb6bdb3..6973c2826 100644 --- a/example/tests/window_message_test.js +++ b/example/tests/window_message_test.js @@ -18,7 +18,7 @@ (async function () { "use strict"; - const { describe, it, expect, run } = SCTest.create({ name: "WindowMessage 传输测试" }); + const { describe, check, expect, run } = SCTest.create({ name: "WindowMessage 传输测试" }); function withTimeout(promise, label, ms = 10000) { let timer = null; @@ -29,95 +29,126 @@ } describe("Sandbox endpoint", () => { - it("default userscript runs in the sandbox window", () => { - expect(typeof unsafeWindow).toBe("object"); - expect(window !== unsafeWindow).toBeTruthy(); - expect(self).toBe(window); - expect(globalThis).toBe(window); - }); + check( + "自动断言", + "default userscript runs in the sandbox window", + () => { + expect(typeof unsafeWindow).toBe("object"); + expect(window !== unsafeWindow).toBeTruthy(); + expect(self).toBe(window); + expect(globalThis).toBe(window); + }, + null, + null, + "保留原有断言体" + ); }); describe("One-shot sendMessage path", () => { - it("GM.setClipboard resolves through the offscreen sendMessage bridge", async () => { - const text = `ScriptCat WindowMessage ${Date.now()} ${Math.random().toString(36).slice(2)}`; - await withTimeout(GM.setClipboard(text, { type: "text", mimetype: "text/plain" }), "GM.setClipboard"); - }); + check( + "自动断言", + "GM.setClipboard resolves through the offscreen sendMessage bridge", + async () => { + const text = `ScriptCat WindowMessage ${Date.now()} ${Math.random().toString(36).slice(2)}`; + await withTimeout(GM.setClipboard(text, { type: "text", mimetype: "text/plain" }), "GM.setClipboard"); + }, + null, + null, + "保留原有断言体" + ); }); describe("Long-lived connect path", () => { - it("GM.xmlHttpRequest receives offscreen response data over a connect channel", async () => { - const marker = `window-message-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const response = await withTimeout( - GM.xmlHttpRequest({ - method: "GET", - url: `https://httpbingo.org/get?marker=${encodeURIComponent(marker)}`, - responseType: "json", - }), - "GM.xmlHttpRequest", - ); + check( + "自动断言", + "GM.xmlHttpRequest receives offscreen response data over a connect channel", + async () => { + const marker = `window-message-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const response = await withTimeout( + GM.xmlHttpRequest({ + method: "GET", + url: `https://httpbingo.org/get?marker=${encodeURIComponent(marker)}`, + responseType: "json", + }), + "GM.xmlHttpRequest" + ); - expect(response.status).toBe(200); - expect(response.finalUrl.includes("httpbingo.org/get")).toBeTruthy(); - expect(typeof response.responseHeaders === "string").toBeTruthy(); - expect(response.response && typeof response.response === "object").toBeTruthy(); + expect(response.status).toBe(200); + expect(response.finalUrl.includes("httpbingo.org/get")).toBeTruthy(); + expect(typeof response.responseHeaders === "string").toBeTruthy(); + expect(response.response && typeof response.response === "object").toBeTruthy(); - const args = - response.response.args || - response.response.query || - response.response.params || - {}; - expect(args.marker?.[0] ?? args.marker).toBe(marker); - }); + const args = response.response.args || response.response.query || response.response.params || {}; + expect(args.marker?.[0] ?? args.marker).toBe(marker); + }, + null, + null, + "保留原有断言体" + ); - it("GM_xmlhttpRequest forwards readyState events over the connect channel", async () => { - const states = []; - const response = await withTimeout( - new Promise((resolve, reject) => { - GM_xmlhttpRequest({ - method: "GET", - url: "https://httpbingo.org/bytes/64", - onreadystatechange: (res) => { - states.push(res.readyState); - }, - onload: resolve, - onerror: reject, - ontimeout: reject, - timeout: 10000, - }); - }), - "GM_xmlhttpRequest readyState", - ); + check( + "自动断言", + "GM_xmlhttpRequest forwards readyState events over the connect channel", + async () => { + const states = []; + const response = await withTimeout( + new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: "GET", + url: "https://httpbingo.org/bytes/64", + onreadystatechange: (res) => { + states.push(res.readyState); + }, + onload: resolve, + onerror: reject, + ontimeout: reject, + timeout: 10000, + }); + }), + "GM_xmlhttpRequest readyState" + ); - expect(response.status).toBe(200); - expect(states.includes(4)).toBeTruthy(); - expect(response.responseText.length > 0).toBeTruthy(); - }); + expect(response.status).toBe(200); + expect(states.includes(4)).toBeTruthy(); + expect(response.responseText.length > 0).toBeTruthy(); + }, + null, + null, + "保留原有断言体" + ); - it("GM_xmlhttpRequest abort disconnects a pending connect channel", async () => { - await withTimeout( - new Promise((resolve, reject) => { - const request = GM_xmlhttpRequest({ - method: "GET", - url: "https://httpbingo.org/delay/5", - onload: () => reject(new Error("request loaded before abort")), - onerror: reject, - ontimeout: reject, - onabort: (res) => { - try { - expect(res.readyState).toBe(0); - expect(res.status).toBe(0); - resolve(); - } catch (error) { - reject(error); - } - }, - timeout: 10000, - }); - setTimeout(() => request.abort(), 100); - }), - "GM_xmlhttpRequest abort", - ); - }); + check( + "自动断言", + "GM_xmlhttpRequest abort disconnects a pending connect channel", + async () => { + await withTimeout( + new Promise((resolve, reject) => { + const request = GM_xmlhttpRequest({ + method: "GET", + url: "https://httpbingo.org/delay/5", + onload: () => reject(new Error("request loaded before abort")), + onerror: reject, + ontimeout: reject, + onabort: (res) => { + try { + expect(res.readyState).toBe(0); + expect(res.status).toBe(0); + resolve(); + } catch (error) { + reject(error); + } + }, + timeout: 10000, + }); + setTimeout(() => request.abort(), 100); + }), + "GM_xmlhttpRequest abort" + ); + }, + null, + null, + "保留原有断言体" + ); }); await run(); From f765a1770d25d032add4184ff1773b778fbdb7fd Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 3 Sep 2026 04:48:05 +0900 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=94=92=20=E5=9B=BA=E5=AE=9A=20sctest?= =?UTF-8?q?=20CDN=20=E5=BC=95=E7=94=A8=E5=88=B0=E6=A1=86=E6=9E=B6=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/tests/early_inject_content_test.js | 2 +- example/tests/early_inject_page_test.js | 2 +- example/tests/gm_api_async_test.js | 2 +- example/tests/gm_api_sync_test.js | 2 +- example/tests/gm_download_test.js | 2 +- example/tests/gm_menu_test.js | 2 +- example/tests/gm_value_test.js | 2 +- example/tests/gm_xhr_cookie_test.js | 2 +- example/tests/gm_xhr_redirect_test.js | 2 +- example/tests/gm_xhr_test.js | 2 +- example/tests/inject_content_test.js | 2 +- example/tests/lib/README.md | 2 +- example/tests/lib/sctest.test.js | 2 +- example/tests/sandbox_test.js | 2 +- example/tests/unwrap_e2e_test.js | 2 +- example/tests/unwrap_test.js | 2 +- example/tests/window_message_test.js | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/example/tests/early_inject_content_test.js b/example/tests/early_inject_content_test.js index 5403ae3ae..5213a0556 100644 --- a/example/tests/early_inject_content_test.js +++ b/example/tests/early_inject_content_test.js @@ -15,7 +15,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/early_inject_page_test.js b/example/tests/early_inject_page_test.js index af21ac28f..b87e9ba1d 100644 --- a/example/tests/early_inject_page_test.js +++ b/example/tests/early_inject_page_test.js @@ -14,7 +14,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index 312ca91df..e20128819 100644 --- a/example/tests/gm_api_async_test.js +++ b/example/tests/gm_api_async_test.js @@ -23,7 +23,7 @@ // @grant GM.cookie // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbingo.org // @connect example.com diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index 88d828170..d34e7a6d8 100644 --- a/example/tests/gm_api_sync_test.js +++ b/example/tests/gm_api_sync_test.js @@ -27,7 +27,7 @@ // @grant GM.setValue // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbingo.org // @connect example.com diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index 33d6a1058..1934ec8b5 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -11,7 +11,7 @@ // @grant GM_setValue // @grant GM_getValue // @grant GM_info -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @connect httpbingo.org // @connect raw.githubusercontent.com // @connect cdn.jsdelivr.net diff --git a/example/tests/gm_menu_test.js b/example/tests/gm_menu_test.js index c2c3ded5d..58205d04a 100644 --- a/example/tests/gm_menu_test.js +++ b/example/tests/gm_menu_test.js @@ -6,7 +6,7 @@ // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // ==/UserScript== (async function () { diff --git a/example/tests/gm_value_test.js b/example/tests/gm_value_test.js index aa7caeaa5..d199c2b08 100644 --- a/example/tests/gm_value_test.js +++ b/example/tests/gm_value_test.js @@ -9,7 +9,7 @@ // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @run-at document-idle // ==/UserScript== diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js index b6351d2e9..ba1f47409 100644 --- a/example/tests/gm_xhr_cookie_test.js +++ b/example/tests/gm_xhr_cookie_test.js @@ -5,7 +5,7 @@ // @description 验证 GM_xmlhttpRequest 的 cookie 参数语义:脚本指定的名称完全覆盖,未指定的名称原样保留(含同名多值场景) // @match https://mockhttp.org/*?GM_XHR_COOKIE_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @connect mockhttp.org // @noframes // ==/UserScript== diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js index 3a8ed4279..0dfb42e1f 100644 --- a/example/tests/gm_xhr_redirect_test.js +++ b/example/tests/gm_xhr_redirect_test.js @@ -6,7 +6,7 @@ // @author you // @match *://*/*?GM_XHR_REDIRECT_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @connect httpbingo.org // @noframes // ==/UserScript== diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index 931b213f1..b5e5aa209 100644 --- a/example/tests/gm_xhr_test.js +++ b/example/tests/gm_xhr_test.js @@ -6,7 +6,7 @@ // @author you // @match *://*/*?GM_XHR_TEST_SC // @grant GM_xmlhttpRequest -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @connect httpbingo.org // @connect nonexistent-domain-abcxyz.test // @connect raw.githubusercontent.com diff --git a/example/tests/inject_content_test.js b/example/tests/inject_content_test.js index 4fa3fb52d..52d0aada5 100644 --- a/example/tests/inject_content_test.js +++ b/example/tests/inject_content_test.js @@ -14,7 +14,7 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== diff --git a/example/tests/lib/README.md b/example/tests/lib/README.md index 54e8cd1d1..abb331e67 100644 --- a/example/tests/lib/README.md +++ b/example/tests/lib/README.md @@ -6,7 +6,7 @@ ## 引入 ```js -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js ``` E2E 运行时会把该框架 URL 重写到本地 mock server(见 `e2e/gm-api.spec.ts` 的 diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index 93c23e145..1be016a49 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -3,7 +3,7 @@ import { resolve } from "node:path"; import { beforeEach, describe as vdescribe, expect as vexpect, it as vit, vi } from "vitest"; const SCTEST_REQUIRE_URL = - "https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js"; + "https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js"; async function loadSCTest() { delete globalThis.SCTest; diff --git a/example/tests/sandbox_test.js b/example/tests/sandbox_test.js index 6480a589c..eba2432db 100644 --- a/example/tests/sandbox_test.js +++ b/example/tests/sandbox_test.js @@ -19,7 +19,7 @@ // @grant window.close // @grant window.focus // @grant unsafeWindow -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @run-at document-end // ==/UserScript== diff --git a/example/tests/unwrap_e2e_test.js b/example/tests/unwrap_e2e_test.js index d7550c808..a6ae43517 100644 --- a/example/tests/unwrap_e2e_test.js +++ b/example/tests/unwrap_e2e_test.js @@ -6,7 +6,7 @@ // @author ScriptCat // @match https://content-security-policy.com/?unwrap_e2e_test // @grant GM_setValue -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @unwrap // ==/UserScript== diff --git a/example/tests/unwrap_test.js b/example/tests/unwrap_test.js index d92978687..53e7a0fe4 100644 --- a/example/tests/unwrap_test.js +++ b/example/tests/unwrap_test.js @@ -8,7 +8,7 @@ // @exclude /test_\w+_excluded/ // @grant GM_setValue // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @unwrap // ==/UserScript== diff --git a/example/tests/window_message_test.js b/example/tests/window_message_test.js index 6973c2826..1cedb95c2 100644 --- a/example/tests/window_message_test.js +++ b/example/tests/window_message_test.js @@ -9,7 +9,7 @@ // @grant GM_xmlhttpRequest // @grant GM.setClipboard // @grant unsafeWindow -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@5319f4e9fc453b1bdd322b9b3478b6a027f53433/example/tests/lib/sctest.js // @connect httpbingo.org // @run-at document-end // @noframes From f53e18b6ec0251ab73e17a2f99d5ab6237d5d0e6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 3 Sep 2026 05:32:39 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=8E=A8=20=E7=BB=9F=E4=B8=80=20userscr?= =?UTF-8?q?ipt=20=E8=AF=8A=E6=96=AD=E9=9D=A2=E6=9D=BF=E4=B8=8E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/references/verification-methods.md | 3 +- e2e/gm-api.spec.ts | 12 +- example/tests/early_inject_content_test.js | 28 +-- example/tests/early_inject_page_test.js | 28 +-- example/tests/gm_api_async_test.js | 58 ++--- example/tests/gm_api_sync_test.js | 58 ++--- example/tests/gm_download_test.js | 2 +- example/tests/gm_menu_test.js | 2 +- example/tests/gm_value_test.js | 2 +- example/tests/gm_xhr_cookie_test.js | 24 +- example/tests/gm_xhr_redirect_test.js | 2 +- example/tests/gm_xhr_test.js | 2 +- example/tests/inject_content_test.js | 22 +- example/tests/lib/README.md | 18 +- example/tests/lib/sctest.js | 256 ++++++++++++++------- example/tests/lib/sctest.test.js | 42 +++- example/tests/sandbox_test.js | 66 +++--- example/tests/unwrap_e2e_test.js | 6 +- example/tests/unwrap_test.js | 13 +- example/tests/window_message_test.js | 10 +- 20 files changed, 393 insertions(+), 261 deletions(-) diff --git a/docs/references/verification-methods.md b/docs/references/verification-methods.md index 464974dc4..46ad56796 100644 --- a/docs/references/verification-methods.md +++ b/docs/references/verification-methods.md @@ -45,7 +45,8 @@ Three scripts keep specialized operation UIs and have to be read on their own te [`gm_download_test.js`](../../example/tests/gm_download_test.js) retains its download panel and human-confirmation flow, while [`gm_menu_test.js`](../../example/tests/gm_menu_test.js) and [`gm_value_test.js`](../../example/tests/gm_value_test.js) retain their menu and multi-frame dashboard UIs. They use -`SCTest.createReportSession()` for the same console/JSON protocol; operation observations are `INFO`, pending human +`SCTest.createReportSession({ reporter: "panel" })` for the same deep-colored diagnostic panel, console and JSON +protocol; their specialized operation UIs remain alongside it. Operation observations are `INFO`, pending human decisions are `MANUAL`, and no manual action is promoted to `PASS` without an explicit verdict. In a session there is nothing to wire up — the collector already recorded the lines, whichever context printed them (a diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 11b3f2e60..ce6954a4a 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -690,15 +690,25 @@ test.describe("GM API", () => { await testRun.run(); const panel = document.getElementById("sctest-panel-host")?.shadowRoot?.querySelector(".sc-panel"); const styles = panel && getComputedStyle(panel); + const panelRoot = panel?.getRootNode(); return { position: styles?.position, width: styles?.width, + top: styles?.top, + diagnosticTable: + panelRoot instanceof ShadowRoot && Boolean(panelRoot.querySelector('[data-sctest="diagnostic-table"]')), adoptedStyleSheets: panel?.getRootNode() instanceof ShadowRoot ? panel.getRootNode().adoptedStyleSheets.length : 0, }; }); - expect(result).toEqual({ position: "fixed", width: "440px", adoptedStyleSheets: 1 }); + expect(result).toEqual({ + position: "fixed", + width: "920px", + top: "12px", + diagnosticTable: true, + adoptedStyleSheets: 1, + }); expect(violations).toEqual([]); const host = page.locator("#sctest-panel-host"); diff --git a/example/tests/early_inject_content_test.js b/example/tests/early_inject_content_test.js index 5213a0556..e662ee02f 100644 --- a/example/tests/early_inject_content_test.js +++ b/example/tests/early_inject_content_test.js @@ -43,7 +43,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -62,7 +62,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); }); @@ -81,7 +81,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -95,7 +95,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -110,7 +110,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); }); @@ -131,7 +131,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); }); @@ -146,7 +146,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); }); @@ -161,7 +161,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); }); @@ -176,7 +176,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -189,7 +189,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -204,7 +204,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -216,7 +216,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -229,7 +229,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -243,7 +243,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); }); diff --git a/example/tests/early_inject_page_test.js b/example/tests/early_inject_page_test.js index b87e9ba1d..44cd3b098 100644 --- a/example/tests/early_inject_page_test.js +++ b/example/tests/early_inject_page_test.js @@ -41,7 +41,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -60,7 +60,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); }); @@ -79,7 +79,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -93,7 +93,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -108,7 +108,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -160,7 +160,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); }); @@ -175,7 +175,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); }); @@ -190,7 +190,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); }); @@ -205,7 +205,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -218,7 +218,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -233,7 +233,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -245,7 +245,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -258,7 +258,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); check( @@ -272,7 +272,7 @@ describe("DOM操作 API 测试", () => { }, null, null, - "保留原有断言体" + null ); }); diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index e20128819..c8ef06896 100644 --- a/example/tests/gm_api_async_test.js +++ b/example/tests/gm_api_async_test.js @@ -46,7 +46,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -61,7 +61,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -74,7 +74,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -87,7 +87,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -104,7 +104,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -122,7 +122,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -134,7 +134,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -147,7 +147,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -161,7 +161,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -181,7 +181,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -203,7 +203,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -220,7 +220,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -235,7 +235,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -272,7 +272,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -292,7 +292,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -322,7 +322,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -338,7 +338,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -352,7 +352,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -368,7 +368,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -381,7 +381,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -396,7 +396,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -413,7 +413,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -428,7 +428,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -442,7 +442,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -456,7 +456,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -471,7 +471,7 @@ }, null, null, - "保留原有断言体" + null ); // 清理所有测试 cookies @@ -497,7 +497,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -511,7 +511,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -525,7 +525,7 @@ }, null, null, - "保留原有断言体" + null ); }); diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index d34e7a6d8..f66effff8 100644 --- a/example/tests/gm_api_sync_test.js +++ b/example/tests/gm_api_sync_test.js @@ -50,7 +50,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -65,7 +65,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -78,7 +78,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -91,7 +91,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -105,7 +105,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -119,7 +119,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -131,7 +131,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -144,7 +144,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -158,7 +158,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -231,7 +231,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -251,7 +251,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -303,7 +303,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -320,7 +320,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -335,7 +335,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -372,7 +372,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -395,7 +395,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -411,7 +411,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -425,7 +425,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -441,7 +441,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -454,7 +454,7 @@ }, null, null, - "保留原有断言体" + null ); // 测试 GM_cookie(action, details, callback) @@ -482,7 +482,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -511,7 +511,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -542,7 +542,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -572,7 +572,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -598,7 +598,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -629,7 +629,7 @@ }, null, null, - "保留原有断言体" + null ); // 清理所有测试 cookies @@ -675,7 +675,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -689,7 +689,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -703,7 +703,7 @@ }, null, null, - "保留原有断言体" + null ); }); diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index 1934ec8b5..6cdc8b0dc 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -410,7 +410,7 @@ const enableTool = true; } function newReportSession() { - return SCTest.createReportSession({ name: "GM_download / GM.download", reporter: "console" }); + return SCTest.createReportSession({ name: "GM_download / GM.download", reporter: "panel" }); } function reportVerdict(session, result, status, detail, name) { diff --git a/example/tests/gm_menu_test.js b/example/tests/gm_menu_test.js index 58205d04a..7b2dfa069 100644 --- a/example/tests/gm_menu_test.js +++ b/example/tests/gm_menu_test.js @@ -17,7 +17,7 @@ const intervalChanging = false; const skipClickCheck = false; - const report = SCTest.createReportSession({ name: "GM_registerMenuCommand", reporter: "console" }); + const report = SCTest.createReportSession({ name: "GM_registerMenuCommand", reporter: "panel" }); report.start(); const reportInfo = (name, actual, detail) => report.note("菜单观察", name, "观察记录", actual, detail); diff --git a/example/tests/gm_value_test.js b/example/tests/gm_value_test.js index d199c2b08..2e3b28f78 100644 --- a/example/tests/gm_value_test.js +++ b/example/tests/gm_value_test.js @@ -381,7 +381,7 @@ /* ══════════════════════════════════════════════════════════ MAIN FRAME LOGIC ══════════════════════════════════════════════════════════ */ - const report = SCTest.createReportSession({ name: "GM_addValueChangeListener dashboard", reporter: "console" }); + const report = SCTest.createReportSession({ name: "GM_addValueChangeListener dashboard", reporter: "panel" }); report.start(); report.note( "启动观察", diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js index ba1f47409..20bd8a709 100644 --- a/example/tests/gm_xhr_cookie_test.js +++ b/example/tests/gm_xhr_cookie_test.js @@ -229,7 +229,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -254,7 +254,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -294,7 +294,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -306,7 +306,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -318,7 +318,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -330,7 +330,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -342,7 +342,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -354,7 +354,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -366,7 +366,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -378,7 +378,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -390,7 +390,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -402,7 +402,7 @@ }, null, null, - "保留原有断言体" + null ); }); diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js index 0dfb42e1f..c55751996 100644 --- a/example/tests/gm_xhr_redirect_test.js +++ b/example/tests/gm_xhr_redirect_test.js @@ -173,7 +173,7 @@ const enableTool = true; describe("GM_xmlhttpRequest 重定向", () => { for (const t of tests) { const label = `${t.useFetch ? "[fetch] " : "[xhr] "}${t.name}`; - check("自动断言", label, () => t.run(t.useFetch ? true : false), null, null, "保留原有断言体"); + check("自动断言", label, () => t.run(t.useFetch ? true : false), null, null, null); } }); diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index b5e5aa209..55d022a2e 100644 --- a/example/tests/gm_xhr_test.js +++ b/example/tests/gm_xhr_test.js @@ -1596,7 +1596,7 @@ const enableTool = true; () => t.run(t.useFetch ? true : false), null, null, - "保留原有断言体" + null ); } }); diff --git a/example/tests/inject_content_test.js b/example/tests/inject_content_test.js index 52d0aada5..5d0c87ac6 100644 --- a/example/tests/inject_content_test.js +++ b/example/tests/inject_content_test.js @@ -35,7 +35,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -55,7 +55,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -72,7 +72,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -87,7 +87,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -102,7 +102,7 @@ }, null, null, - "保留原有断言体" + null ); }); @@ -117,7 +117,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -130,7 +130,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -145,7 +145,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -157,7 +157,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -170,7 +170,7 @@ }, null, null, - "保留原有断言体" + null ); check( @@ -184,7 +184,7 @@ }, null, null, - "保留原有断言体" + null ); }); diff --git a/example/tests/lib/README.md b/example/tests/lib/README.md index abb331e67..d49e9686b 100644 --- a/example/tests/lib/README.md +++ b/example/tests/lib/README.md @@ -20,7 +20,7 @@ const { describe, check, note, itManual, run } = SCTest.create({ name: "GM API describe("GM 存储 API", () => { check( - "自动断言", + "GM 存储 API", "GM_setValue 写入字符串", () => { GM_setValue("k", "v"); @@ -32,7 +32,7 @@ describe("GM 存储 API", () => { ); check( - "异步断言", + "GM 存储 API", "GM.getValue 读取字符串", async () => (await GM.getValue("k")) === "v", "v", @@ -65,8 +65,15 @@ run(); - `onError: "WARN"`:predicate 抛异常时记为 `WARN`; - `required: false`:把结果标为非必需观察项,仍保留原始状态和诊断字段。 +如果旧式用例没有传 `expected`、`actual` 或 `detail`,框架会记录用例内部每个 `expect` +matcher 的预期/实际值,并根据用例名称生成说明;没有内部 matcher 的用例则明确显示“执行不抛出异常”或 predicate 的布尔结果。这样迁移不会因为保留旧断言体而丢失诊断信息,也不会把环境观察误写成自动通过。 + `note(category, name, expected, actual, detail)` 只登记一条 `INFO` 观察记录,不伪造自动断言。 +建议把 `category` 写成能力或行为分组,把 `name` 写成可独立判断的断言,把 `expected` 和 `actual` 写成同一维度的值,再用 +`detail` 说明为什么检查以及失败后如何解释。旧脚本传入的 `"自动断言"` 会自动归入当前 `describe` +名称,避免诊断表中出现没有语义的分类。 + ## 结果状态与兼容入口 统一状态为 `PASS`、`FAIL`、`WARN`、`INFO`、`SKIP`、`MANUAL`。`MANUAL` @@ -82,10 +89,11 @@ run(); ## 自定义运行器 -需要保留专用操作面板的脚本可使用 `SCTest.createReportSession()`,不必把下载、菜单或跨 iframe 操作 UI 改写成标准面板: +需要保留专用操作面板的脚本可使用 `SCTest.createReportSession()`;将 reporter 设为 `"panel"` +可在保留操作 UI 的同时显示统一诊断面板: ```js -const report = SCTest.createReportSession({ name: "GM_download", reporter: "console" }); +const report = SCTest.createReportSession({ name: "GM_download", reporter: "panel" }); report.start(); const pending = report.manual("人工操作", "确认下载内容", "内容正确", "待检查", "打开文件并核对内容"); // 操作完成后: @@ -103,7 +111,7 @@ session 提供 `start()`、`record()`、`update()`、异步 `check()`、`note()` | reporter | 启用条件 | 说明 | | -------- | ----------------------------------- | ----------------------------------------------------------------------- | | Console | 恒定开启 | DevTools 逐条输出状态、expected/actual/detail,末尾输出稳定 JSON marker | -| Panel | `page` 运行上下文 | Shadow DOM 浮层面板,宿主 id `sctest-panel-host` | +| Panel | `page` 运行上下文 | Shadow DOM 深色诊断表,宿主 id `sctest-panel-host` | | Log | `background` / `crontab` 运行上下文 | `GM_log` 逐条输出,落到「运行日志」页 | Console 的机器可读行以 `[SCTEST_RESULT] ` 开头,后面是 `protocol: "sctest/v1"` 的 JSON。summary 包含 diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js index 111d99ee0..a293398c6 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -97,10 +97,21 @@ return true; } - function makeExpect() { + function makeExpect(onObserve) { + function observe(matcher, expected, actual) { + if (onObserve) { + onObserve({ + matcher: matcher, + expected: stringify(expected), + actual: stringify(actual), + }); + } + } + return function expect(actual) { return { toBe: function (expected) { + observe("toBe", expected, actual); if (actual !== expected) { throw AssertionError( "期望 " + stringify(expected) + ",实际 " + stringify(actual), @@ -110,6 +121,7 @@ } }, toEqual: function (expected) { + observe("toEqual", expected, actual); if (!deepEqual(actual, expected)) { var b = stringify(expected); var a = stringify(actual); @@ -117,14 +129,17 @@ } }, toBeTruthy: function () { + observe("toBeTruthy", "truthy", actual); if (!actual) throw AssertionError("期望为真值,实际 " + stringify(actual), "truthy", stringify(actual)); }, toBeTypeOf: function (expected) { var t = typeof actual; + observe("toBeTypeOf", expected, t); if (t !== expected) throw AssertionError("期望类型 " + expected + ",实际 " + t, expected, t); }, toMatch: function (pattern) { var text = String(actual); + observe("toMatch", String(pattern), text); var ok = pattern instanceof RegExp ? pattern.test(text) : text.indexOf(String(pattern)) !== -1; if (!ok) { throw AssertionError("期望匹配 " + String(pattern) + ",实际 " + stringify(text), String(pattern), text); @@ -142,6 +157,7 @@ didThrow = true; thrown = e; } + observe("toThrow", pattern ? "throw " + String(pattern) : "throw", didThrow ? "throw" : "no throw"); if (!didThrow) throw AssertionError("期望抛出异常,实际未抛出", "throw", "no throw"); if (pattern) { var msg = String((thrown && thrown.message) || thrown); @@ -222,6 +238,7 @@ var currentSuite = null; var lastStartedAt = 0; var runInfo = null; + var activeCase = null; function describe(name, optsOrFn, maybeFn) { var suiteOpts = typeof optsOrFn === "function" ? {} : optsOrFn || {}; @@ -260,20 +277,22 @@ onFail: data.onFail, onError: data.onError, manualVerdict: null, + observations: [], }); } function check(category, name, predicate, expected, actual, detail, options) { if (typeof name === "function") { options = {}; - detail = "保留原有断言体"; + detail = null; actual = null; expected = null; predicate = name; name = category; category = currentSuite ? currentSuite.name : "自动断言"; } - pushCase(category, name, predicate, "check", { + var resolvedCategory = category === "自动断言" && currentSuite ? currentSuite.name : category; + pushCase(resolvedCategory, name, predicate, "check", { expected: expected, actual: actual, detail: detail, @@ -326,6 +345,42 @@ }); } + function inferAssertionFields(c) { + if (!c.observations.length) return null; + return { + expected: c.observations + .map(function (observation) { + return observation.matcher + ": " + observation.expected; + }) + .join("\n"), + actual: c.observations + .map(function (observation) { + return observation.matcher + ": " + observation.actual; + }) + .join("\n"), + }; + } + + function fallbackFields(c, passed, error) { + var inferred = inferAssertionFields(c); + if (inferred) return inferred; + if (error) return { expected: "不抛出异常", actual: "抛出 " + error }; + return passed === false + ? { expected: "true", actual: "false" } + : { expected: "执行不抛出异常", actual: "未抛出异常" }; + } + + function resolveDetail(c, status) { + var detail = resolveValue(c.detailSource); + if (detail && detail !== "保留原有断言体") return detail; + if (status === STATUS.FAIL) return "检查「" + c.name + "」失败;请对照错误、期望值和实际值定位原因。"; + if (status === STATUS.WARN) return "检查「" + c.name + "」未满足,但按可选诊断记录为警告。"; + if (status === STATUS.SKIP) return "检查「" + c.name + "」未执行:当前环境不提供所需条件。"; + if (status === STATUS.MANUAL) return "检查「" + c.name + "」需要人工操作后裁决。"; + if (status === STATUS.INFO) return "记录「" + c.name + "」的环境观察,不产生自动断言。"; + return "检查「" + c.name + "」的内部断言;所有断言均满足。"; + } + async function runCase(c, reporters) { c.error = null; if (c.kind === "check") { @@ -333,36 +388,48 @@ c.expected = null; c.actual = null; c.detail = ""; + c.observations = []; } if (c.kind === "manual") { c.status = STATUS.MANUAL; + c.detail = resolveDetail(c, c.status); } else if (c.kind === "note") { c.status = STATUS.INFO; } else { var started = now(); try { + activeCase = c; var passed = await c.fn(); c.expected = resolveValue(c.expectedSource); c.actual = resolveValue(c.actualSource); + var passFields = fallbackFields(c, passed, null); + if (c.expected == null) c.expected = passFields.expected; + if (c.actual == null) c.actual = passFields.actual; // Existing assertion bodies return undefined after their expect() calls. Treat only // an explicit false as a predicate failure so those bodies can migrate one-for-one. var matched = passed !== false; - c.detail = resolveValue(c.detailSource) || (matched ? "符合预期" : "不符合预期"); c.status = matched ? STATUS.PASS : normalizeStatus(c.onFail || STATUS.FAIL); + c.detail = resolveDetail(c, c.status); } catch (e) { if (e instanceof SkipSignal) { c.status = STATUS.SKIP; c.error = e.reason; c.required = false; - c.detail = c.detail || "当前环境未提供该检查"; + var skipFields = fallbackFields(c, null, null); + c.expected = resolveValue(c.expectedSource) || skipFields.expected; + c.actual = resolveValue(c.actualSource) || "当前环境未提供"; + c.detail = resolveDetail(c, c.status); } else { c.status = normalizeStatus(c.onError || c.onFail || STATUS.FAIL); c.error = String((e && e.message) || e); - if (c.expected == null) c.expected = resolveValue(c.expectedSource) || (e && e.expected) || null; - if (c.actual == null) c.actual = resolveValue(c.actualSource) || (e && e.actual) || null; - c.detail = resolveValue(c.detailSource) || "检测过程抛出异常"; + var errorFields = fallbackFields(c, null, c.error); + if (c.expected == null) + c.expected = resolveValue(c.expectedSource) || (e && e.expected) || errorFields.expected; + if (c.actual == null) c.actual = resolveValue(c.actualSource) || (e && e.actual) || errorFields.actual; + c.detail = resolveDetail(c, c.status); } } + activeCase = null; c.durationMs = Math.round(now() - started); } var result = toResult(c); @@ -437,6 +504,9 @@ if (!suite.auto && c.kind !== "manual") { c.status = STATUS.SKIP; c.required = false; + c.expected = resolveValue(c.expectedSource) || "点击运行后执行此检查"; + c.actual = resolveValue(c.actualSource) || "尚未运行"; + c.detail = resolveDetail(c, c.status); emitCase(reporters, toResult(c)); continue; } @@ -455,7 +525,9 @@ note: note, it: it, itManual: itManual, - expect: makeExpect(), + expect: makeExpect(function (observation) { + if (activeCase) activeCase.observations.push(observation); + }), run: run, }; } @@ -660,26 +732,30 @@ // ---------- PanelReporter ---------- var PANEL_CSS = [ - ":host{all:initial}", - ".sc-panel{position:fixed;right:16px;bottom:16px;width:440px;max-height:80vh;display:flex;", - "flex-direction:column;overflow:hidden;border-radius:12px;border:1px solid var(--sc-border);", - "background:var(--sc-card);color:var(--sc-fg);font-family:Inter,system-ui,sans-serif;font-size:12px;", - "box-shadow:0 8px 24px rgba(0,0,0,.15);z-index:2147483647}", + ":host{all:initial;--sc-bg:#0b1821;--sc-card:#102632;--sc-fg:#eaf3f8;--sc-muted:#8ea9b8;", + "--sc-muted-bg:#0e202b;--sc-border:#315264;--sc-primary:#72daf9;--sc-success:#65e6ad;", + "--sc-success-fg:#071c12;--sc-success-bg:#65e6ad;--sc-destructive:#ff7888;", + "--sc-destructive-fg:#2a060b;--sc-destructive-bg:#ff7888;--sc-warning-bg:#ffc766;--sc-warning-fg:#291700}", + ".sc-panel{position:fixed;top:12px;right:12px;bottom:auto;width:min(920px,calc(100vw - 24px));", + "max-height:calc(100vh - 24px);display:flex;flex-direction:column;overflow:hidden;", + "border:1px solid var(--sc-border);border-radius:12px;background:var(--sc-bg);color:var(--sc-fg);", + 'box-shadow:0 18px 60px rgba(0,0,0,.42);font:13px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;z-index:2147483647}', "[hidden]{display:none!important}", ".sc-panel[data-min='1'] .sc-body,.sc-panel[data-min='1'] .sc-sum,", ".sc-panel[data-min='1'] .sc-bar,.sc-panel[data-min='1'] .sc-foot{display:none}", - ".sc-head{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--sc-border)}", + ".sc-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--sc-border);background:var(--sc-card)}", ".sc-grip{color:var(--sc-muted);font-size:14px;cursor:move;user-select:none}", ".sc-title-wrap{display:flex;min-width:0;flex:1;flex-direction:column;gap:2px}", - ".sc-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}", - ".sc-meta{font-size:11px;color:var(--sc-muted);font-weight:400}", - ".sc-btn{display:inline-flex;cursor:pointer;align-items:center;justify-content:center;gap:5px;border:1px solid var(--sc-border);background:var(--sc-card);color:var(--sc-fg);", - "border-radius:6px;padding:4px 9px;font-size:11px;font-family:inherit}", + ".sc-title{font-weight:750;letter-spacing:.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}", + ".sc-meta{font-size:11px;color:var(--sc-muted);font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}", + ".sc-btn{display:inline-flex;cursor:pointer;align-items:center;justify-content:center;gap:5px;border:1px solid #426579;background:#142f3e;color:var(--sc-fg);", + "border-radius:7px;padding:6px 9px;font-size:11px;font-family:inherit}", + ".sc-btn:hover{background:#1d4558}", ".sc-icon-btn{display:inline-flex;width:24px;height:24px;align-items:center;justify-content:center;border:0;padding:0}", ".sc-btn:disabled{cursor:wait;opacity:.55}", ".sc-icon{display:inline-flex;flex:none;align-items:center;justify-content:center;line-height:0}", - ".sc-btn-primary{background:var(--sc-primary);border-color:var(--sc-primary);color:#fff}", - ".sc-sum{padding:12px 14px;border-bottom:1px solid var(--sc-border);background:var(--sc-bg);", + ".sc-btn-primary{background:#1d637d;border-color:#72daf9;color:#eaf3f8}", + ".sc-sum{padding:11px 14px;border-bottom:1px solid #203d4c;background:var(--sc-bg);", "display:flex;flex-direction:column;gap:10px}", ".sc-chips{display:flex;gap:6px;align-items:center;flex-wrap:wrap}", ".sc-status-row,.sc-run-row,.sc-toolbar{display:flex;align-items:center;gap:8px}", @@ -688,59 +764,54 @@ ".sc-status-pass{background:var(--sc-success-bg);color:var(--sc-success-fg)}", ".sc-status-fail{background:var(--sc-destructive-bg);color:var(--sc-destructive-fg)}", ".sc-status-warn{background:var(--sc-warning-bg);color:var(--sc-warning-fg)}", - ".sc-status-info,.sc-status-skip,.sc-status-manual{background:var(--sc-muted-bg);color:var(--sc-muted)}", + ".sc-status-info,.sc-status-skip,.sc-status-manual{background:#315264;color:#d9e6ec}", ".sc-chip{display:inline-flex;align-items:center;gap:4px;border-radius:9999px;padding:3px 9px;font-size:11px;font-weight:500}", ".sc-chip-pass{background:var(--sc-success-bg);color:var(--sc-success-fg)}", ".sc-chip-fail{background:var(--sc-destructive-bg);color:var(--sc-destructive-fg)}", ".sc-chip-warn{background:var(--sc-warning-bg);color:var(--sc-warning-fg)}", - ".sc-chip-info,.sc-chip-skip,.sc-chip-manual{background:var(--sc-muted-bg);color:var(--sc-muted)}", - ".sc-progress{height:6px;border-radius:9999px;background:var(--sc-muted-bg);overflow:hidden;display:flex}", + ".sc-chip-info,.sc-chip-skip,.sc-chip-manual{background:#315264;color:#d9e6ec}", + ".sc-progress{height:6px;border-radius:9999px;background:#315264;overflow:hidden;display:flex}", ".sc-progress i{display:block;height:6px}", - ".sc-toolbar{padding:8px 14px;border-bottom:1px solid var(--sc-border)}", - ".sc-segments{display:flex;gap:2px;padding:2px;border-radius:6px;background:var(--sc-muted-bg)}", - ".sc-segment{cursor:pointer;border:0;border-radius:4px;padding:3px 10px;background:transparent;color:var(--sc-muted);font:inherit;font-size:11px}", - ".sc-segment[data-active='1']{background:var(--sc-card);color:var(--sc-fg);font-weight:600}", - ".sc-search{display:flex;min-width:0;flex:1;align-items:center;gap:6px;border:1px solid var(--sc-border);border-radius:6px;padding:4px 8px;background:var(--sc-card);color:var(--sc-muted)}", + ".sc-diagnostic-hint{padding:9px 14px;color:#a9c0cc;background:#0e202b;border-bottom:1px solid #203d4c;font-size:11px}", + ".sc-toolbar{padding:8px 14px;border-bottom:1px solid #203d4c;background:var(--sc-bg)}", + ".sc-segments{display:flex;gap:2px;padding:2px;border-radius:6px;background:#0e202b;overflow:auto}", + ".sc-segment{cursor:pointer;border:0;border-radius:4px;padding:3px 10px;background:transparent;color:var(--sc-muted);font:inherit;font-size:11px;white-space:nowrap}", + ".sc-segment[data-active='1']{background:#142f3e;color:var(--sc-fg);font-weight:600}", + ".sc-search{display:flex;min-width:0;flex:1;align-items:center;gap:6px;border:1px solid #426579;border-radius:7px;padding:4px 8px;background:#142f3e;color:var(--sc-muted)}", ".sc-search input{min-width:0;flex:1;border:0;outline:0;background:transparent;color:var(--sc-fg);font:inherit;font-size:11px}", - ".sc-body{overflow:auto;flex:1}", - ".sc-suite{display:flex;align-items:center;gap:7px;padding:7px 14px;background:var(--sc-bg);", - "border-top:1px solid var(--sc-border);font-weight:600;cursor:pointer}", + ".sc-body{max-height:calc(100vh - 265px);overflow:auto;flex:1;background:var(--sc-bg)}", + ".sc-table-head{display:grid;grid-template-columns:58px minmax(0,23%) minmax(0,17%) minmax(0,17%) minmax(0,1fr);", + "padding:8px 9px;color:var(--sc-primary);background:var(--sc-card);border-bottom:1px solid #203d4c;font-size:11px;font-weight:700}", + ".sc-table-head span{min-width:0;overflow-wrap:anywhere}", + ".sc-suite{display:flex;align-items:center;gap:7px;padding:9px 14px;background:var(--sc-muted-bg);", + "border-top:1px solid #203d4c;color:var(--sc-primary);font-weight:750;cursor:pointer}", ".sc-suite .sc-suite-name{flex:1}", ".sc-suite-stat{border-radius:9999px;padding:2px 8px;background:var(--sc-success-bg);color:var(--sc-success-fg);font-size:11px;font-weight:500}", ".sc-suite-stat[data-failed='1']{background:var(--sc-destructive-bg);color:var(--sc-destructive-fg)}", ".sc-suite-stat[data-manual='1']{display:inline-flex;align-items:center;gap:4px;background:var(--sc-warning-bg);color:var(--sc-warning-fg)}", - ".sc-case{display:flex;align-items:center;gap:8px;padding:6px 14px 6px 34px}", + ".sc-case{display:flex;align-items:center;gap:8px;padding:8px 14px 6px 34px;background:var(--sc-bg)}", ".sc-case span{flex:1}", ".sc-case-label{min-width:0;display:flex;flex-direction:column;gap:2px}", ".sc-case-category{color:var(--sc-muted);font-size:10px;font-weight:400}", ".sc-case-status{font-size:10px;font-style:normal;font-weight:700}", - ".sc-case-manual{background:var(--sc-warning-bg)}", - ".sc-manual-pass{width:22px;height:22px;padding:0;border-color:var(--sc-success-fg);background:var(--sc-success-bg);color:var(--sc-success-fg)}", - ".sc-manual-fail{width:22px;height:22px;padding:0;border-color:var(--sc-destructive-fg);background:var(--sc-destructive-bg);color:var(--sc-destructive-fg)}", + ".sc-case-manual{background:#352c1e}", + ".sc-manual-pass{width:22px;height:22px;padding:0;border-color:#65e6ad;background:#1d4938;color:#65e6ad}", + ".sc-manual-fail{width:22px;height:22px;padding:0;border-color:#ff7888;background:#54252c;color:#ff7888}", ".sc-dur{font-size:11px;color:var(--sc-muted)}", - ".sc-detail{margin:0 14px 8px 34px;padding:8px 10px;border-radius:6px;border-left:2px solid var(--sc-border);", - "background:var(--sc-muted-bg);color:var(--sc-fg);font-family:'JetBrains Mono',monospace;", - "font-size:11px;white-space:pre-wrap}", - ".sc-hint{display:flex;gap:6px;margin:0 14px 8px 34px;padding:7px 10px;border-radius:6px;background:var(--sc-muted-bg);", + ".sc-detail{display:grid;grid-template-columns:max-content minmax(0,1fr) max-content minmax(0,1fr);gap:5px 10px;margin:0 14px 8px 34px;padding:8px 10px;border-radius:6px;border-left:2px solid var(--sc-border);", + "background:var(--sc-muted-bg);color:#c3d6df;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:11px;overflow-wrap:anywhere}", + ".sc-detail-field{display:contents}.sc-detail-key{color:var(--sc-primary);font-family:system-ui,sans-serif;font-weight:700}.sc-detail-value{min-width:0;white-space:pre-wrap;overflow-wrap:anywhere}", + ".sc-detail-wide{grid-column:1/-1}.sc-hint{display:flex;gap:6px;margin:0 14px 8px 34px;padding:7px 10px;border-radius:6px;background:var(--sc-muted-bg);", "color:var(--sc-muted);font-size:11px}", - ".sc-params{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid var(--sc-border)}", + ".sc-params{display:flex;align-items:center;gap:8px;padding:8px 14px;border-bottom:1px solid #203d4c}", ".sc-params-label{font-weight:600}", ".sc-field{display:flex;min-width:0;flex:1;align-items:center;gap:6px;color:var(--sc-muted);white-space:nowrap}", ".sc-field-compact{flex:0 0 108px}", ".sc-params input{min-width:0;flex:1;border:1px solid var(--sc-border);border-radius:6px;padding:3px 8px;", - "background:var(--sc-card);color:var(--sc-fg);font-family:'JetBrains Mono',monospace;font-size:11px}", - ".sc-foot{display:flex;align-items:center;gap:8px;padding:9px 14px;border-top:1px solid var(--sc-border);", - "background:var(--sc-bg)}", + "background:#142f3e;color:var(--sc-fg);font-family:'JetBrains Mono',monospace;font-size:11px}", + ".sc-foot{display:flex;align-items:center;gap:8px;padding:9px 14px;border-top:1px solid #203d4c;background:var(--sc-muted-bg)}", ".sc-foot .sc-sumline{flex:1;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--sc-muted)}", - "@media (max-width:520px){.sc-panel{right:8px;bottom:8px;width:calc(100vw - 16px);max-height:calc(100vh - 16px)}.sc-toolbar{flex-wrap:wrap}.sc-search{flex-basis:100%}}", - ":host{--sc-bg:#fafafa;--sc-card:#fff;--sc-fg:#1a1a1a;--sc-muted:#767676;--sc-muted-bg:#f0f0f0;", - "--sc-border:#e5e5e5;--sc-primary:#1296db;--sc-success:#34c759;--sc-success-fg:#0c8833;--sc-success-bg:#e8f9ec;", - "--sc-destructive:#e7000b;--sc-destructive-fg:#c10007;--sc-destructive-bg:#fdecec;", - "--sc-warning-bg:#fff4e6;--sc-warning-fg:#c46c00}", - "@media (prefers-color-scheme: dark){:host{--sc-bg:#1e1e1e;--sc-card:#151515;--sc-fg:#e5e5e5;", - "--sc-muted:#8a8a8a;--sc-muted-bg:#2a2a2a;--sc-border:#2a2a2a;--sc-primary:#3aacef;", - "--sc-success-fg:#6fdd8a;--sc-success-bg:#1e3520;--sc-destructive:#ff6669;", - "--sc-destructive-fg:#ff9a9a;--sc-destructive-bg:#3a1a1c;--sc-warning-bg:#352c1e;--sc-warning-fg:#ffb84d}}", + "@media (max-width:720px){.sc-panel{top:8px;right:8px;width:calc(100vw - 16px);max-height:calc(100vh - 16px)}.sc-toolbar{flex-wrap:wrap}.sc-search{flex-basis:100%}.sc-table-head{grid-template-columns:48px minmax(0,1fr) minmax(0,1fr)}}", ].join(""); // Constructable stylesheet 通过 CSSOM 安装到 Shadow Root,不属于页面的 inline