diff --git a/docs/references/verification-methods.md b/docs/references/verification-methods.md index 25c7b3c07..46ad56796 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,77 @@ 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. - -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. - -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): +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 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({ 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 +`@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 +112,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..38f9545c1 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -28,13 +28,70 @@ 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; + environment: { + tool: string; + version: string; + time: string; + context: string; + url?: string; + manager?: string; + }; + 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; + failDetail?: string; + errorDetail?: string; + 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 +562,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 +574,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 +610,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,24 +687,63 @@ 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 summary = 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, + summary, }; }); - expect(result).toEqual({ position: "fixed", width: "440px", adoptedStyleSheets: 1 }); + expect(result).toMatchObject({ + position: "fixed", + width: "920px", + top: "12px", + diagnosticTable: true, + adoptedStyleSheets: 1, + }); + expect(result.summary).toMatchObject({ + protocol: "sctest/v1", + total: 6, + counts: { PASS: 1, FAIL: 1, WARN: 1, INFO: 1, SKIP: 1, MANUAL: 1 }, + overall: "FAIL", + environment: { tool: "sctest", version: "1", context: "page" }, + }); + expect(result.summary.suites[0].cases).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "warning case", status: "WARN", expected: true, actual: false }), + expect.objectContaining({ + name: "info case", + status: "INFO", + expected: "页面可见", + actual: "页面已加载", + detail: "记录环境观察,不产生自动断言", + }), + expect.objectContaining({ name: "manual case", status: "MANUAL" }), + ]) + ); expect(violations).toEqual([]); const host = page.locator("#sctest-panel-host"); @@ -658,10 +755,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); @@ -682,12 +785,36 @@ test.describe("GM API", () => { }); }); await host.locator('[data-sctest="export-json"]').click(); + await expect(host.locator('[data-sctest="export-json"]')).toContainText("已复制"); 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 +833,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 +842,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 +860,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 +878,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 +896,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 +911,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 +926,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 +944,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 +962,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 +980,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 +1003,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..0cebda84c 100644 --- a/example/tests/early_inject_content_test.js +++ b/example/tests/early_inject_content_test.js @@ -15,138 +15,236 @@ // @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@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== // 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + null + ); + + check( + "自动断言", + "GM_setValue - 数字", + () => { + GM_setValue("test_number", 12345); + const value = GM_getValue("test_number"); + expect(value).toBe(12345); + }, + null, + 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, + null + ); + + check( + "自动断言", + "GM_getValue - 默认值", + () => { + const value = GM_getValue("non_existent_key", "默认值"); + expect(value).toBe("默认值"); + }, + null, + null, + null + ); + + check( + "自动断言", + "GM_listValues", + () => { + const keys = GM_listValues(); + expect(Array.isArray(keys)).toBeTruthy(); + expect(keys.length >= 3).toBeTruthy(); + }, + null, + 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, + null + ); }); await run(); diff --git a/example/tests/early_inject_page_test.js b/example/tests/early_inject_page_test.js index 44a6a142b..384f1a840 100644 --- a/example/tests/early_inject_page_test.js +++ b/example/tests/early_inject_page_test.js @@ -14,168 +14,266 @@ // @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@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== // 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + null + ); + + check( + "自动断言", + "GM_setValue - 数字", + () => { + GM_setValue("test_number", 12345); + const value = GM_getValue("test_number"); + expect(value).toBe(12345); + }, + null, + 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, + null + ); + + check( + "自动断言", + "GM_getValue - 默认值", + () => { + const value = GM_getValue("non_existent_key", "默认值"); + expect(value).toBe("默认值"); + }, + null, + null, + null + ); + + check( + "自动断言", + "GM_listValues", + () => { + const keys = GM_listValues(); + expect(Array.isArray(keys)).toBeTruthy(); + expect(keys.length >= 3).toBeTruthy(); + }, + null, + 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, + null + ); }); await run(); diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index e04473708..ab48f2d02 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@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/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 @@ -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, + 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, + null + ); + + check( + "自动断言", + "GM.setValue - 数字", + async () => { + await GM.setValue("test_number", 42); + const value = await GM.getValue("test_number"); + expect(value).toBe(42); + }, + null, + 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, + 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, + 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, + null + ); + + check( + "自动断言", + "GM.getValue - 默认值", + async () => { + const value = await GM.getValue("non_existent_key", "default_value"); + expect(value).toBe("default_value"); + }, + null, + null, + null + ); + + check( + "自动断言", + "GM.listValues", + async () => { + const values = await GM.listValues(); + expect(Array.isArray(values)).toBeTruthy(); + expect(values.includes("test_string")).toBeTruthy(); + }, + null, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + null + ); }); describe("GM 标签页 API", () => { - it("GM.openInTab (不执行)", async () => { - // 不实际打开标签页,只测试函数是否存在 - expect(GM.openInTab).toBeTypeOf("function"); - }); + check( + "自动断言", + "GM.openInTab (不执行)", + async () => { + // 不实际打开标签页,只测试函数是否存在 + expect(GM.openInTab).toBeTypeOf("function"); + }, + null, + 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, + 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, + 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, + 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, + 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, + 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, + null + ); + + check( + "自动断言", + "GM.cookie.delete", + async () => { + await GM.cookie.delete({ + url: "http://www.example.com/path", + name: "scriptcat_async_test2", + }); + }, + null, + 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, + 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, + 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, + 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, + null + ); }); await run(); diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index d6a424919..09aacf4cb 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@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/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 @@ -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, + 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, + null + ); + + check( + "自动断言", + "GM_setValue - 数字", + () => { + GM_setValue("test_number", 42); + const value = GM_getValue("test_number"); + expect(value).toBe(42); + }, + null, + null, + null + ); + + check( + "自动断言", + "GM_setValue - 布尔值", + () => { + GM_setValue("test_boolean", true); + const value = GM_getValue("test_boolean"); + expect(value).toBe(true); + }, + null, + 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, + 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, + null + ); + + check( + "自动断言", + "GM_getValue - 默认值", + () => { + const value = GM_getValue("non_existent_key", "default_value"); + expect(value).toBe("default_value"); + }, + null, + null, + null + ); + + check( + "自动断言", + "GM_listValues", + () => { + const values = GM_listValues(); + expect(Array.isArray(values)).toBeTruthy(); + expect(values.includes("test_string")).toBeTruthy(); + }, + null, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + null + ); }); describe("GM 标签页 API", () => { - it("GM_openInTab (不执行)", () => { - // 不实际打开标签页,只测试函数是否存在 - expect(GM_openInTab).toBeTypeOf("function"); - }); + check( + "自动断言", + "GM_openInTab (不执行)", + () => { + // 不实际打开标签页,只测试函数是否存在 + expect(GM_openInTab).toBeTypeOf("function"); + }, + null, + 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, + null + ); }); describe("GM Cookie API", () => { - it("GM_cookie 函数存在", () => { - expect(GM_cookie).toBeTypeOf("function"); - }); + check( + "自动断言", + "GM_cookie 函数存在", + () => { + expect(GM_cookie).toBeTypeOf("function"); + }, + null, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + null + ); }); await run(); diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index a3df884e0..52fd17e43 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@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/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: "panel" }); + } + + 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..9d2e102ea 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@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/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: "panel" }); + 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..57f099791 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@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/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: "panel" }); + 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..91471fcd0 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@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/example/tests/lib/sctest.js // @connect mockhttp.org // @noframes // ==/UserScript== @@ -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, + 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, + 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, + null + ); - it("m01:浏览器无、脚本指定单值 → 应为脚本值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues( - lastCookieMap, - "m01", - ["new"] - ); - }); + check( + "自动断言", + "m00:浏览器无、脚本未指定 → 不应出现", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m00", []); + }, + null, + 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, + null + ); - it("m10:浏览器单值、脚本未指定 → 应保留浏览器原值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues( - lastCookieMap, - "m10", - ["old"] - ); - }); + check( + "自动断言", + "m02:浏览器无、脚本指定多值 → 应为脚本两个值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m02", ["new1", "new2"]); + }, + null, + null, + null + ); - it("m11:浏览器单值、脚本指定单值 → 应覆盖为脚本值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues( - lastCookieMap, - "m11", - ["new"] - ); - }); + check( + "自动断言", + "m10:浏览器单值、脚本未指定 → 应保留浏览器原值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m10", ["old"]); + }, + null, + 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, + 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, + 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, + null + ); - it("m22:浏览器多值、脚本指定多值 → 应完全覆盖为脚本两个值", () => { - if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); - assertCookieValues(lastCookieMap, "m22", [ - "new1", - "new2", - ]); - }); + check( + "自动断言", + "m21:浏览器多值、脚本指定单值 → 应完全覆盖为脚本单一值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m21", ["new"]); + }, + null, + null, + null + ); + + check( + "自动断言", + "m22:浏览器多值、脚本指定多值 → 应完全覆盖为脚本两个值", + () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m22", ["new1", "new2"]); + }, + null, + null, + null + ); }); try { diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js index 5234ad05b..c10a1e820 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@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/example/tests/lib/sctest.js // @connect httpbingo.org // @noframes // ==/UserScript== @@ -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, null); } }); diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index df5b76754..96ff59799 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@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/example/tests/lib/sctest.js // @connect httpbingo.org // @connect nonexistent-domain-abcxyz.test // @connect raw.githubusercontent.com @@ -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, + null + ); } }); diff --git a/example/tests/inject_content_test.js b/example/tests/inject_content_test.js index 4d68fa9f7..e4fc896e6 100644 --- a/example/tests/inject_content_test.js +++ b/example/tests/inject_content_test.js @@ -14,101 +14,178 @@ // @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@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== (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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + null + ); }); await run(); diff --git a/example/tests/lib/README.md b/example/tests/lib/README.md index e96013837..95132b5a7 100644 --- a/example/tests/lib/README.md +++ b/example/tests/lib/README.md @@ -1,85 +1,153 @@ # sctest — example/tests 共用测试框架 -零依赖、零构建的单文件测试框架,供 `example/tests/` 下的用户脚本共用。 +零依赖、零构建的单文件测试框架,供 `example/tests/` +下的用户脚本共用。每条结果都遵循同一份诊断协议,可同时被人类、DevTools、`GM_log` 和 E2E 读取。 ## 引入 ```js -// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@3c3ded1030b21182c1bdbfa20544bb8bf202f3a1/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 存储 API", + "GM_setValue 写入字符串", + () => { + GM_setValue("k", "v"); + return GM_getValue("k") === "v"; + }, + "v", + GM_getValue("k"), + "写入后读取到相同值" + ); + + check( + "GM 存储 API", + "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` 以及失败/异常说明都可传值、同步函数或 +Promise-returning 函数。`options` 支持: -统一 `expect(actual).matcher(expected)`,**实际值在前**。 +- `onFail: "WARN"`:predicate 为假时记为 `WARN`; +- `onError: "WARN"`:predicate 抛异常时记为 `WARN`; +- `failDetail`:只在 predicate 返回 `false` 时显示的说明; +- `errorDetail`:只在 predicate 抛异常时显示的说明; +- `required: false`:把结果标为非必需观察项,仍保留原始状态和诊断字段。 -| matcher | 说明 | -|---|---| -| `toBe(expected)` | `!==` 严格比较 | -| `toEqual(expected)` | 结构化递归深比较(键顺序不敏感;区分 NaN/null、undefined 键) | -| `toBeTruthy()` | 真值 | -| `toBeTypeOf(type)` | `typeof` 比较 | -| `toMatch(pattern)` | 正则或子串 | -| `toThrow(pattern?)` | 被测目标须为函数;可选校验异常消息 | +如果旧式用例没有传 `expected`、`actual` 或 `detail`,框架会记录用例内部每个 `expect` +matcher 的预期/实际值,并根据用例名称生成说明;没有内部 matcher 的用例则明确显示“执行不抛出异常”或 predicate 的布尔结果。这样迁移不会因为保留旧断言体而丢失诊断信息,也不会把环境观察误写成自动通过。 -## 主动跳过 +`note(category, name, expected, actual, detail)` 只登记一条 `INFO` 观察记录,不伪造自动断言。 -条件不满足时用 `SCTest.skip(reason)` 从用例体内退出,记为跳过而非失败,原因会出现在 -控制台、面板与 `GM_log` 里: +访问可能不存在、被权限拦截或跨 realm 的宿主对象时,优先使用共享的安全探针: ```js -it("需要浏览器原生下载", async () => { - const v = await awaitVerdict(); - if (v.verdict === "skip") SCTest.skip(`${v.reason} (未落盘)`); - expect(v.ok).toBeTruthy(); -}); +const { safe, read, UNAVAILABLE, formatValue } = SCTest; +const value = read(() => unsafeWindow.someOptionalApi); +check( + "宿主能力", + "可选 API 可读", + () => value !== UNAVAILABLE, + "可读取的 API", + formatValue(value), + "读取失败时保留不可用状态,不把异常伪装成通过", + { onFail: "WARN", onError: "WARN", required: false } +); +``` + +`safe(fn)` 返回 `{ ok, value }` 或 `{ ok: false, error }`,`read(fn)` 在异常时返回稳定的 +`UNAVAILABLE` 哨兵。`formatValue` 可安全显示 realm、函数、循环引用和不可用值;它适合放进 `actual` 或 `detail`,避免诊断 +代码自身因读取异常中断。 + +建议把 `category` 写成能力或行为分组,把 `name` 写成可独立判断的断言,把 `expected` 和 `actual` 写成同一维度的值,再用 +`detail` 说明为什么检查以及失败后如何解释。旧脚本传入的 `"自动断言"` 会自动归入当前 `describe` +名称,避免诊断表中出现没有语义的分类。 + +## 结果状态与兼容入口 + +统一状态为 `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()`;将 reporter 设为 `"panel"` +可在保留操作 UI 的同时显示统一诊断面板: + +```js +const report = SCTest.createReportSession({ name: "GM_download", reporter: "panel" }); +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 | 启用条件 | 说明 | +| -------- | ----------------------------------- | ----------------------------------------------------------------------- | +| Console | 恒定开启 | DevTools 逐条输出状态、expected/actual/detail,末尾输出稳定 JSON marker | +| Panel | `page` 运行上下文 | Shadow DOM 深色诊断表,宿主 id `sctest-panel-host` | +| Log | `background` / `crontab` 运行上下文 | `GM_log` 逐条输出,落到「运行日志」页 | -三个 reporter 可叠加,由 `SCTest.create({ reporter })` 控制,默认 `"auto"`: +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` 必须保留并展示。 -| reporter | auto 模式下的启用条件 | 说明 | -|---|---|---| -| Console | **恒定开启** | 全量输出,末尾三行汇总是 e2e 的解析契约,勿改格式 | -| Panel | 运行上下文为 `page` | Shadow DOM 浮层面板,宿主 id `sctest-panel-host` | -| Log | 运行上下文为 `background` / `crontab` | `GM_log` + 结构化 label,落到「运行日志」页 | +Panel 保留原有 +`sctest-panel-host`、CSP 防护、拖动、折叠、重跑和参数选择器,并提供状态 chips、expected/actual/detail 诊断列、分类分组、状态筛选、搜索、复制文本和复制 JSON。复制优先使用 Clipboard API,失败时回退到 textarea,并在按钮上显示“已复制”或“复制失败”。 +面板提示按 FAIL → WARN → INFO/SKIP → MANUAL 给出阅读顺序;MANUAL 使用独立的琥珀色状态和人工确认按钮,确认后同步更新面板、Console、`GM_log` 和 JSON。early-start 脚本应使用 +`{ reporter: "console" }`,避免 document-start 的 DOM 断言被面板初始化改变。 +JSON marker 与面板导出会安全处理不可用值、realm 对象、函数、`BigInt` 和循环引用,不让诊断输出反过来遮蔽原始结果。 -运行上下文由 `GM_info.scriptMetaStr` 里的 `@background` / `@crontab` 判定 —— 后台脚本跑在 offscreen -文档里,`document` 是存在的,所以不能用 `typeof document === "undefined"` 判断。 +每份 summary 的 `environment` 记录 `tool`、协议版本、生成时间、运行上下文,并在可读取时附上页面 URL 与脚本管理器版本。 +Console 末尾的 `[SCTEST_RESULT] ` marker 是唯一稳定的机器读取入口;其前面的逐条文本与 `console.table` 供 DevTools 人类排查。 -用 `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..d04c5cfe1 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -5,7 +5,16 @@ (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]"; + var UNAVAILABLE = Object.create(null); // GM_info.script 不含 background/crontab 字段(见 src/app/service/content/gm_api/gm_info.ts), // 只能从 metadata 原文判断运行上下文。 @@ -26,13 +35,109 @@ return ""; } + function safe(fn) { + try { + return { ok: true, value: fn() }; + } catch (error) { + return { ok: false, error: error }; + } + } + + function read(fn) { + var result = safe(fn); + return result.ok ? result.value : UNAVAILABLE; + } + + function formatError(error) { + if (error === UNAVAILABLE) return "不可用"; + try { + if (error && error.name && error.message) return error.name + ": " + error.message; + return String(error); + } catch (e) { + return "未知异常"; + } + } + + function realmLabel(value) { + var currentWindow = read(function () { + return typeof window === "undefined" ? UNAVAILABLE : window; + }); + if (currentWindow !== UNAVAILABLE && value === currentWindow) return "sandbox window"; + var currentGlobal = read(function () { + return typeof globalThis === "undefined" ? UNAVAILABLE : globalThis; + }); + if (currentGlobal !== UNAVAILABLE && value === currentGlobal) return "sandbox globalThis"; + var currentSelf = read(function () { + return typeof self === "undefined" ? UNAVAILABLE : self; + }); + if (currentSelf !== UNAVAILABLE && value === currentSelf) return "sandbox self"; + var pageWindow = read(function () { + return typeof unsafeWindow === "undefined" ? UNAVAILABLE : unsafeWindow; + }); + if (pageWindow !== UNAVAILABLE && value === pageWindow) return "page unsafeWindow"; + return ""; + } + function stringify(value) { + if (value === UNAVAILABLE) return "<不可用>"; + var realm = realmLabel(value); + if (realm) return realm; if (typeof value === "function") return "[Function " + (value.name || "anonymous") + "]"; try { - var out = JSON.stringify(value); + var seen = []; + var out = JSON.stringify(value, function (key, current) { + if (current && typeof current === "object") { + if (seen.indexOf(current) !== -1) return "[Circular]"; + seen.push(current); + } + return current; + }); return out === undefined ? String(value) : out; } catch (e) { - return String(value); + try { + return String(value); + } catch (stringError) { + return "<无法显示的值>"; + } + } + } + + function formatValue(value) { + if (value === UNAVAILABLE) return "<不可用>"; + var realm = realmLabel(value); + if (realm) return realm; + if (value === undefined) return "undefined"; + if (value === null) return "null"; + if (typeof value === "function") return "function " + (value.name || "(anonymous)"); + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value); + if (typeof value === "symbol") return String(value); + return stringify(value); + } + + function stringifyReport(value, space) { + var ancestors = []; + try { + return JSON.stringify( + value, + function (key, current) { + while (ancestors.length && ancestors[ancestors.length - 1] !== this) ancestors.pop(); + if (current === UNAVAILABLE) return "<不可用>"; + var realm = realmLabel(current); + if (realm) return realm; + if (typeof current === "bigint") return String(current); + if (typeof current === "function") return formatValue(current); + if (typeof current === "symbol") return String(current); + if (current && typeof current === "object") { + if (ancestors.indexOf(current) !== -1) return "[Circular]"; + ancestors.push(current); + } + return current; + }, + space + ); + } catch (error) { + return JSON.stringify({ protocol: "sctest/v1", error: "报告序列化失败: " + formatError(error) }, null, space); } } @@ -89,10 +194,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), @@ -102,6 +218,7 @@ } }, toEqual: function (expected) { + observe("toEqual", expected, actual); if (!deepEqual(actual, expected)) { var b = stringify(expected); var a = stringify(actual); @@ -109,14 +226,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); @@ -134,6 +254,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); @@ -151,22 +272,109 @@ 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; + } + + async function resolveValueAsync(value) { + return await resolveValue(value); + } + + function overallStatus(counts) { + return counts.FAIL ? STATUS.FAIL : counts.WARN ? STATUS.WARN : counts.MANUAL ? STATUS.MANUAL : STATUS.PASS; + } + + function createEnvironment(context) { + var environment = { + tool: "sctest", + version: "1", + time: new Date().toISOString(), + context: context, + }; + var url = read(function () { + return typeof location === "undefined" ? UNAVAILABLE : location.href; + }); + if (url !== UNAVAILABLE) environment.url = String(url); + var info = read(function () { + if (typeof GM !== "undefined" && GM && GM.info) return GM.info; + return typeof GM_info === "undefined" ? UNAVAILABLE : GM_info; + }); + if (info !== UNAVAILABLE && info) { + var manager = read(function () { + return info.scriptHandler ? info.scriptHandler + (info.version ? " " + info.version : "") : UNAVAILABLE; + }); + if (manager !== UNAVAILABLE) environment.manager = String(manager); + } + return environment; + } + + function createSummary(name, context, suites, startedAt, environment) { + 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: overallStatus(counts), + durationMs: Math.round(now() - startedAt), + suites: outSuites, + environment: environment || createEnvironment(context), + }; + } + 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; + var activeCase = null; + var environment = createEnvironment(context); 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 +384,205 @@ } } - 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, + failDetailSource: data.failDetail == null ? null : data.failDetail, + errorDetailSource: data.errorDetail == null ? null : data.errorDetail, + manualVerdict: null, + observations: [], + }); + } + + function check(category, name, predicate, expected, actual, detail, options) { + if (typeof name === "function") { + options = {}; + detail = null; + actual = null; + expected = null; + predicate = name; + name = category; + category = currentSuite ? currentSuite.name : "自动断言"; + } + var resolvedCategory = category === "自动断言" && currentSuite ? currentSuite.name : category; + pushCase(resolvedCategory, name, predicate, "check", { + expected: expected, + actual: actual, + detail: detail, + required: !options || options.required !== false, + onFail: options && options.onFail, + onError: options && options.onError, + failDetail: options && options.failDetail, + errorDetail: options && options.errorDetail, + }); + } + + 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); + }); + } + + 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: "未抛出异常" }; + } + + async function resolveDetail(c, status, isError) { + var specificDetail = await resolveValueAsync(isError ? c.errorDetailSource : c.failDetailSource); + if (specificDetail) return specificDetail; + var detail = await resolveValueAsync(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; - c.expected = null; - c.actual = null; + if (c.kind === "check") { + c.required = c.requiredSource; + c.expected = null; + c.actual = null; + c.detail = ""; + c.observations = []; + } if (c.kind === "manual") { c.status = STATUS.MANUAL; + c.detail = await resolveDetail(c, c.status, false); + } else if (c.kind === "note") { + c.status = STATUS.INFO; } else { var started = now(); try { - await c.fn(); - c.status = STATUS.PASS; + activeCase = c; + var passed = await c.fn(); + c.expected = await resolveValueAsync(c.expectedSource); + c.actual = await resolveValueAsync(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.status = matched ? STATUS.PASS : normalizeStatus(c.onFail || STATUS.FAIL); + c.detail = await resolveDetail(c, c.status, false); } catch (e) { if (e instanceof SkipSignal) { c.status = STATUS.SKIP; c.error = e.reason; + c.required = false; + var skipFields = fallbackFields(c, null, null); + c.expected = (await resolveValueAsync(c.expectedSource)) || skipFields.expected; + c.actual = (await resolveValueAsync(c.actualSource)) || "当前环境未提供"; + c.detail = await resolveDetail(c, c.status, false); } 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; + var errorFields = fallbackFields(c, null, c.error); + if (c.expected == null) + c.expected = (await resolveValueAsync(c.expectedSource)) || (e && e.expected) || errorFields.expected; + if (c.actual == null) + c.actual = (await resolveValueAsync(c.actualSource)) || (e && e.actual) || errorFields.actual; + c.detail = await resolveDetail(c, c.status, true); } } + activeCase = null; 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, environment); + } + + 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 +599,21 @@ 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, + environment: environment, + runnable: true, + onRunManual: null, + onManualVerdict: null, + }; var reporters = global.SCTest.__buildReporters(opts, context, runInfo); runInfo.onRunManual = function (suiteName) { return rerunSuites(reporters, suiteName, false); @@ -308,7 +621,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 +649,215 @@ 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; + c.expected = (await resolveValueAsync(c.expectedSource)) || "点击运行后执行此检查"; + c.actual = (await resolveValueAsync(c.actualSource)) || "尚未运行"; + c.detail = await resolveDetail(c, c.status, 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(function (observation) { + if (activeCase) activeCase.observations.push(observation); + }), + run: run, + }; + } + + function createReportSession(options) { + var opts = options || {}; + var name = opts.name || "未命名报告"; + var context = opts.context || detectContext(currentMetaStr()); + var environment = createEnvironment(context); + var reporters = global.SCTest.__buildReporters({ reporter: opts.reporter || "console" }, context, { + name: name, + context: context, + suites: [], + environment: environment, + runnable: false, + }); + 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, + environment + ); + 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: [], environment: environment }); + }); + } + + 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(); + var resolvedExpected = await resolveValueAsync(expected); + var resolvedActual = await resolveValueAsync(actual); + return record({ + category: category, + name: caseName, + status: passed !== false ? STATUS.PASS : normalizeStatus(checkOptions.onFail || STATUS.FAIL), + expected: resolvedExpected, + actual: resolvedActual, + detail: + (await resolveValueAsync(passed ? detail : checkOptions.failDetail)) || + (await resolveValueAsync(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), + }); + } + var resolvedExpectedOnError = await resolveValueAsync(expected); + var resolvedActualOnError = await resolveValueAsync(actual); + return record({ + category: category, + name: caseName, + status: normalizeStatus(checkOptions.onError || checkOptions.onFail || STATUS.FAIL), + error: String((e && e.message) || e), + expected: resolvedExpectedOnError, + actual: resolvedActualOnError == null ? "抛出 " + formatError(e) : resolvedActualOnError, + detail: + (await resolveValueAsync(checkOptions.errorDetail)) || + (await resolveValueAsync(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, environment); + 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,49 +872,77 @@ 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 fields = { expected: c.expected, actual: c.actual, detail: c.detail, required: c.required }; + var line = icon + " [" + c.status + "] " + c.name + " (" + c.durationMs + "ms)" + formatDetails(c); + 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 if (c.status === STATUS.MANUAL) console.log("%c" + line, "color: #8a6d1d;", 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)"); + if (typeof console.table === "function") { + var rows = []; + summary.suites.forEach(function (suite) { + suite.cases.forEach(function (c) { + rows.push({ + category: c.category, + name: c.name, + status: c.status, + expected: c.expected, + actual: c.actual, + detail: c.detail, + }); + }); + }); + try { + console.table(rows); + } catch (e) { + /* 某些宿主 console.table 只接受原生数组 */ + } + } + console.log(SCTEST_MARKER + " " + stringifyReport(summary)); }, }; } // ---------- 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-manual-bg:#6b4b1f;--sc-manual-fg:#ffe0a0}", + ".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-btn:focus-visible,.sc-segment:focus-visible,.sc-search input:focus-visible{outline:2px solid var(--sc-primary);outline-offset:2px}", ".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}", @@ -401,54 +950,58 @@ ".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{background:#315264;color:#d9e6ec}", + ".sc-status-manual{background:var(--sc-manual-bg);color:var(--sc-manual-fg)}", ".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-progress{height:6px;border-radius:9999px;background:var(--sc-muted-bg);overflow:hidden;display:flex}", + ".sc-chip-warn{background:var(--sc-warning-bg);color:var(--sc-warning-fg)}", + ".sc-chip-info,.sc-chip-skip{background:#315264;color:#d9e6ec}", + ".sc-chip-manual{background:var(--sc-manual-bg);color:var(--sc-manual-fg)}", + ".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);", + "position:sticky;top:0;z-index:2;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-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-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:#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-destructive);", - "background:var(--sc-destructive-bg);color:var(--sc-destructive-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}}", + ".sc-foot-note{color:var(--sc-muted);font-size:10px;white-space:nowrap}", + "@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)}.sc-foot-note{display:none}}", ].join(""); // Constructable stylesheet 通过 CSSOM 安装到 Shadow Root,不属于页面的 inline