diff --git a/docs/verification.md b/docs/verification.md index a040d4e87..158eb192e 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -201,18 +201,27 @@ copy that file's inline fixture and helpers into your scratch script rather than ### The in-page self-test pattern The canonical way to verify GM APIs / injection is a userscript that **runs assertions in the page and prints a -summary line**, which the harness parses from the console. The bundled scripts in -[`example/tests/`](../example/tests/) (e.g. `gm_api_sync_test.js`, `gm_api_async_test.js`, -`inject_content_test.js`, `sandbox_test.js`, `window_message_test.js`) do exactly this. The exact line varies by -script — what matters is that each emits a `通过`/`Passed` and a `失败`/`Failed` count the harness can parse: +summary the harness parses from the console**. The bundled scripts in [`example/tests/`](../example/tests/) all +share one framework, [`example/tests/lib/sctest.js`](../example/tests/lib/sctest.js) (loaded via `@require`), and +therefore all emit the same summary lines: ``` -总计: 12 | 通过: 12 | 失败: 0 # inject_content_test.js / sandbox_test.js (combined line) -总测试数: 12 / 通过: 12 / 失败: 0 # gm_api_sync_test.js / gm_api_async_test.js (counts on separate lines) -Total: 12 | Passed: 12 | Failed: 0 # window_message_test.js (English) +总测试数: 12 +通过: 12 +失败: 0 +跳过: 0 (34ms) ``` -Collect and assert on it from your scratch script (the regex below matches all three layouts): +Scripts that run in a background / crontab context have no visible page, so the same framework additionally emits +one `GM_log` entry per case with structured labels (`sctest`, `status`), which show up as 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. + +> The one exception is [`gm_value_test.js`](../example/tests/gm_value_test.js) — an interactive multi-frame +> dashboard demo for `GM_addValueChangeListener` with no machine-checkable assertions. It is intentionally left +> off the framework and prints no summary line. + +Collect and assert on the summary from your scratch script (the regex below matches the framework's summary lines): ```ts const logs: string[] = []; diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index de2674058..8df8fe6e7 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -1,9 +1,9 @@ import fs from "fs"; import path from "path"; import os from "os"; -import { createServer } from "http"; +import { createServer, STATUS_CODES, type IncomingMessage, type ServerResponse } from "http"; import type { AddressInfo } from "net"; -import { test as base, expect, chromium, type BrowserContext } from "@playwright/test"; +import { test as base, expect, chromium, type BrowserContext, type Page } from "@playwright/test"; import { autoApprovePermissions, installScriptByCode } from "./utils"; const MOCK_CONNECT_HOST = "127.0.0.1"; @@ -27,6 +27,16 @@ type GMApiMockServer = { close: () => Promise; }; +type SCTestBrowserApi = { + skip(reason: string): never; + create(options: { name: string; reporter: string }): { + describe(name: string, register: () => void): void; + it(name: string, run: () => void): void; + expect(value: unknown): { toBe(expected: unknown): void }; + run(): Promise; + }; +}; + const test = base.extend< { context: BrowserContext; @@ -107,6 +117,65 @@ function patchScriptCode(code: string): string { .replace(/https:\/\/cdn\.jsdelivr\.net\/npm\//g, "https://unpkg.com/"); } +function parseCookies(req: IncomingMessage): Record { + const jar: Record = {}; + for (const pair of (req.headers.cookie || "").split(";")) { + const idx = pair.indexOf("="); + if (idx <= 0) continue; + jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim(); + } + return jar; +} + +function streamInChunks( + res: ServerResponse, + body: Buffer, + contentType: string, + chunkCount: number, + gapMs: number, + delayMs: number +): void { + res.writeHead(200, { "Content-Type": contentType }); + const chunkSize = Math.ceil(body.length / chunkCount); + let sent = 0; + const tick = () => { + if (res.destroyed) return; + if (sent >= body.length) { + res.end(); + return; + } + res.write(body.subarray(sent, sent + chunkSize)); + sent += chunkSize; + setTimeout(tick, gapMs); + }; + setTimeout(tick, delayMs); +} + +// 大响应体:前段按 120ms 分块下发,保证 readyState 3 的 progress 事件;尾段一次灌完并立刻 +// 结束——Chrome XHR 的 progress 节流(50ms 内只派发一次,其余推迟)会把尾段最后一个 progress +// 推迟到 readyState 已是 4 之后才派发,gm_xhr_test.js 的事件集合断言正依赖这个事件。 +function streamLargeBody(res: ServerResponse, body: Buffer, contentType: string): void { + res.writeHead(200, { "Content-Type": contentType }); + const head = Math.floor(body.length / 6); + res.write(body.subarray(0, head)); + setTimeout(() => { + if (res.destroyed) return; + res.write(body.subarray(head, head * 2)); + setTimeout(() => { + if (res.destroyed) return; + res.end(body.subarray(head * 2)); + }, 120); + }, 120); +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + }); +} + async function startGMApiMockServer(): Promise { const server = createServer((req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); @@ -121,12 +190,20 @@ async function startGMApiMockServer(): Promise { if (url.pathname === "/get") { res.writeHead(200, { "Content-Type": "application/json" }); - const args = Object.fromEntries(url.searchParams.entries()); + const args = Object.fromEntries([...url.searchParams].map(([name, value]) => [name, [value]])); res.end( - JSON.stringify({ - url: `http://${req.headers.host}${url.pathname}`, - args, - }) + JSON.stringify( + { + // Include the query string — gm_xhr_redirect_test.js asserts the reflected url matches + // the exact request URL it sent, search params included (mirrors real httpbingo.org/get). + url: `http://${req.headers.host}${url.pathname}${url.search}`, + method: req.method, + args, + headers: req.headers, + }, + null, + 2 + ) ); return; } @@ -154,6 +231,160 @@ async function startGMApiMockServer(): Promise { return; } + if (url.pathname === "/lib/sctest.js") { + const source = fs.readFileSync(path.join(__dirname, "../example/tests/lib/sctest.js"), "utf-8"); + res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" }); + res.end(source); + return; + } + + if (url.pathname === "/redirect-to") { + const target = url.searchParams.get("url") || "/"; + res.writeHead(302, { Location: target }); + res.end(); + return; + } + + // 以下路由复刻 httpbingo.org 上 gm_xhr_test.js 用到的端点语义,让整套用例不依赖外网。 + const base64Match = url.pathname.match(/^\/base64\/(.+)$/); + if (base64Match) { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(Buffer.from(base64Match[1], "base64")); + return; + } + + const statusMatch = url.pathname.match(/^\/status\/(\d{3})$/); + if (statusMatch) { + const code = Number(statusMatch[1]); + res.writeHead(code, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ code, description: STATUS_CODES[code] || "" }, null, 2)); + return; + } + + if (url.pathname === "/ip") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ origin: req.socket.remoteAddress || MOCK_CONNECT_HOST })); + return; + } + + if (url.pathname === "/headers") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ headers: req.headers }, null, 2)); + return; + } + + if (url.pathname === "/response-headers") { + const echoed = Object.fromEntries(url.searchParams.entries()); + res.writeHead(200, { ...echoed, "Content-Type": "application/json" }); + res.end(JSON.stringify(echoed, null, 2)); + return; + } + + if (url.pathname === "/cookies/set" && url.searchParams.size > 0) { + const [name, value] = url.searchParams.entries().next().value as [string, string]; + res.writeHead(200, { + "Content-Type": "application/json", + "Set-Cookie": `${name}=${value}; Path=/`, + }); + res.end(JSON.stringify({ cookies: parseCookies(req) })); + return; + } + + if (url.pathname === "/cookies/delete") { + res.writeHead(200, { + "Content-Type": "application/json", + "Set-Cookie": [...url.searchParams.keys()].map((name) => `${name}=; Path=/; Max-Age=0`), + }); + res.end(JSON.stringify({ cookies: {} })); + return; + } + + if (url.pathname === "/cookies") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ cookies: parseCookies(req) })); + return; + } + + const basicAuthMatch = url.pathname.match(/^\/basic-auth\/([^/]+)\/([^/]+)$/); + if (basicAuthMatch) { + const expected = `Basic ${Buffer.from(`${basicAuthMatch[1]}:${basicAuthMatch[2]}`).toString("base64")}`; + if (req.headers.authorization !== expected) { + res.writeHead(401, { + "WWW-Authenticate": 'Basic realm="Fake Realm"', + "Content-Type": "application/json", + }); + res.end(JSON.stringify({ authenticated: false })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ authenticated: true, user: basicAuthMatch[1] })); + return; + } + + if (url.pathname === "/post" || url.pathname === "/delete") { + void readBody(req).then((raw) => { + if (res.destroyed) return; + const data = raw.toString("utf-8"); + const contentType = `${req.headers["content-type"] || ""}`; + const form: Record = {}; + let json: unknown = null; + if (contentType.includes("application/x-www-form-urlencoded")) { + for (const [name, value] of new URLSearchParams(data)) { + (form[name] ??= []).push(value); + } + } + if (contentType.includes("application/json")) { + try { + json = JSON.parse(data); + } catch { + json = null; + } + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ method: req.method, args: Object.fromEntries(url.searchParams), data, form, json })); + }); + return; + } + + // httpbingo /drip:延迟 delay 秒后,把 numbytes 字节分批写完,跨度 duration 秒。 + // 用例断言至少 4 次 onprogress,所以必须真的分多块下发而不是一次性写完。 + if (url.pathname === "/drip") { + const numbytes = Number(url.searchParams.get("numbytes") || 10); + const duration = Number(url.searchParams.get("duration") || 0) * 1000; + const delay = Number(url.searchParams.get("delay") || 0) * 1000; + streamInChunks(res, Buffer.alloc(numbytes, "a"), "application/octet-stream", 8, duration / 8, delay); + return; + } + + // raw.githubusercontent.com 上的三个固定文件:用例只断言事件序列与响应体类型, + // 不断言文件内容,所以这里只需复刻「小文本 / 大非 JSON 文本 / 大 JSON」三种形态。 + const rawMatch = url.pathname.match(/^\/raw\/(.+)$/); + if (rawMatch) { + if (rawMatch[1] === "large-file.json") { + const body = Buffer.from(JSON.stringify(Array.from({ length: 20_000 }, (_, i) => ({ id: i, name: `n${i}` })))); + streamLargeBody(res, body, "application/json"); + return; + } + if (rawMatch[1] === "big.txt") { + streamLargeBody(res, Buffer.alloc(600_000, "the quick brown fox "), "text/plain"); + return; + } + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("root = true\n\n[*]\nindent_style = space\nindent_size = 2\n"); + return; + } + + if (url.pathname === "/translate_a/single") { + // 复刻 translate.googleapis.com 的响应头,用例断言的正是这几个头字段透传是否正确。 + res.writeHead(200, { + "Content-Type": "application/json; charset=utf-8", + "Reporting-Endpoints": 'default="/_/TranslateApiHttp/web-reports?context=eJzj4tDSz8ksLtHPTS3JSC0GAB6vBQA"', + "Cross-Origin-Opener-Policy": "same-origin", + }); + res.end(JSON.stringify({ sentences: [{ trans: "来了!!", orig: "くる!!" }], src: "ja" })); + return; + } + const bytesMatch = url.pathname.match(/^\/bytes\/(\d+)$/); if (bytesMatch) { const size = Number(bytesMatch[1]); @@ -185,6 +416,16 @@ async function startGMApiMockServer(): Promise { ); }); + // Node 的 HTTP 解析器只认标准方法,会把 gm_xhr_test.js 的 `method: "FOOBAR"` 直接判为协议错误。 + // 真实 httpbingo 对未知方法回 405,这里手写同样的响应,避免退化成连接被重置。 + server.on("clientError", (err: NodeJS.ErrnoException, socket) => { + if (err.code === "HPE_INVALID_METHOD" && socket.writable) { + socket.end("HTTP/1.1 405 Method Not Allowed\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: 0\r\n\r\n"); + return; + } + socket.destroy(); + }); + await new Promise((resolve, reject) => { const onError = (error: Error) => reject(error); server.once("error", onError); @@ -212,23 +453,41 @@ function patchTargetMatchCode(code: string, targetUrl: string): string { const url = new URL(targetUrl); const targetPattern = `${url.protocol}//${url.hostname}/*${url.search}`; return code.replace( - /^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|early_inject_content|early_inject_page|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test)$/gm, + /^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|early_inject_content|early_inject_page|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test|GM_XHR_REDIRECT_TEST_SC|GM_DOWNLOAD_TEST_SC|GM_XHR_TEST_SC)$/gm, `// @match ${targetPattern}` ); } +// 把框架的 CDN @require 重写到本地 mock server:CI 不依赖外网,且始终测工作区版本而非 CDN 上的旧版。 +function patchRequireCode(code: string, origin: string): string { + return code.replace( + /https:\/\/cdn\.jsdelivr\.net\/gh\/scriptscat\/scriptcat@[^/]+\/example\/tests\/lib\/sctest\.js/g, + `${origin}/lib/sctest.js` + ); +} + function patchGMApiTestCode(code: string, mockOrigin: string): string { const mockHost = new URL(mockOrigin).host; - return code - .replace(/^\/\/\s*@connect\s+api\.github\.com$/gm, `// @connect ${MOCK_CONNECT_HOST}`) - .replace(/^\/\/\s*@connect\s+httpbingo\.org$/gm, `// @connect ${MOCK_CONNECT_HOST}`) - .replace(/https:\/\/api\.github\.com\/repos\/scriptscat\/scriptcat/g, `${mockOrigin}/repos/scriptscat/scriptcat`) - .replace(/https:\/\/httpbingo\.org\/get/g, `${mockOrigin}/get`) - .replace(/https:\/\/httpbingo\.org\/bytes\/64/g, `${mockOrigin}/bytes/64`) - .replace(/https:\/\/httpbingo\.org\/delay\/5/g, `${mockOrigin}/delay/5`) - .replace(/https:\/\/www\.tampermonkey\.net\/favicon\.ico/g, `${mockOrigin}/favicon.ico`) - .replace(/api\.github\.com\/repos\/scriptscat\/scriptcat/g, `${mockHost}/repos/scriptscat/scriptcat`) - .replace(/httpbingo\.org\/get/g, `${mockHost}/get`); + return ( + code + .replace(/^\/\/\s*@connect\s+api\.github\.com$/gm, `// @connect ${MOCK_CONNECT_HOST}`) + .replace(/^\/\/\s*@connect\s+httpbingo\.org$/gm, `// @connect ${MOCK_CONNECT_HOST}`) + .replace(/https:\/\/api\.github\.com\/repos\/scriptscat\/scriptcat/g, `${mockOrigin}/repos/scriptscat/scriptcat`) + .replace(/https:\/\/httpbingo\.org\/get/g, `${mockOrigin}/get`) + .replace(/https:\/\/httpbingo\.org\/bytes\/64/g, `${mockOrigin}/bytes/64`) + .replace(/https:\/\/httpbingo\.org\/delay\/5/g, `${mockOrigin}/delay/5`) + .replace(/https:\/\/www\.tampermonkey\.net\/favicon\.ico/g, `${mockOrigin}/favicon.ico`) + .replace(/api\.github\.com\/repos\/scriptscat\/scriptcat/g, `${mockHost}/repos/scriptscat/scriptcat`) + .replace(/httpbingo\.org\/get/g, `${mockHost}/get`) + // gm_xhr_redirect_test.js / gm_download_test.js / gm_xhr_test.js build every request URL off + // this constant rather than writing literal https://httpbingo.org/... URLs. + .replace(/const HB = "https:\/\/httpbingo\.org";/, `const HB = "${mockOrigin}";`) + // gm_xhr_test.js 拉三个固定的 raw.githubusercontent.com 文件,按文件名映射到本地 /raw/。 + // 这两个域的 @connect 行刻意保持原样:改写后会和 httpbingo 那行一起变成重复的 + // @connect 127.0.0.1,而重复的 @connect 值会让脚本完全不执行。 + .replace(/https:\/\/raw\.githubusercontent\.com\/\S*?\/([\w.-]+)\?/g, `${mockOrigin}/raw/$1?`) + .replace(/https:\/\/translate\.googleapis\.com/g, mockOrigin) + ); } async function runTestScript( @@ -237,10 +496,18 @@ async function runTestScript( scriptFile: string, targetUrl: string, timeoutMs: number, - options?: { patchCode?: (code: string) => string } + options?: { + patchCode?: (code: string) => string; + requireOrigin?: string; + // B 类文件的 sctest 套件是 auto:false(真实下载副作用),页面加载不会自动跑,需要先点面板的 + // 「运行」按钮。首次加载时 ConsoleReporter 已经打过一次汇总(那时用例全被预置为 skip, + // 即 "通过: 0 / 失败: 0"),所以点击后必须等**新的一次**汇总,不能沿用已有值。 + beforeCollect?: (page: Page) => Promise; + } ): Promise<{ passed: number; failed: number; 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); code = patchTargetMatchCode(code, targetUrl); code = options?.patchCode ? options.patchCode(code) : code; @@ -252,25 +519,66 @@ async function runTestScript( let passed = -1; let failed = -1; + // 「失败:」是三行汇总里的最后一行,用它计数即可判定又打完了一整组汇总。 + 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 (failMatch) { + failed = parseInt(failMatch[2], 10); + summaryCount++; + } }); await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); - await expect - .poll(() => passed >= 0 && failed >= 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) - .toBe(true) - .catch(() => undefined); + + if (options?.beforeCollect) { + // 顺序很重要:先等页面加载时那组汇总打完(那时 auto:false 的用例还全是 skip, + // 汇总是 "通过: 0 / 失败: 0"),再点按钮,最后等下一组汇总。 + // 若在 goto 之后立刻取快照,首次汇总往往还没打,会让第二个轮询被它立即满足而读到 0/0。 + await expect + .poll(() => summaryCount > 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) + .toBe(true) + .catch(() => undefined); + const seenBefore = summaryCount; + await options.beforeCollect(page); + await expect + .poll(() => summaryCount > seenBefore, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) + .toBe(true) + .catch(() => undefined); + } else { + await expect + .poll(() => passed >= 0 && failed >= 0, { timeout: timeoutMs, intervals: [100, 250, 500, 1_000] }) + .toBe(true) + .catch(() => undefined); + } await page.close(); return { passed, failed, logs }; } +// 设计稿统一为“运行全部”入口;旧面板若仍提供 suite 专属按钮则优先使用。 +// 两条路径都只执行自动用例,itManual 保持待人工确认。 +function clickSuiteRunButton(suiteName: string) { + return async (page: Page): Promise => { + const host = page.locator("#sctest-panel-host"); + await host.waitFor({ state: "attached", timeout: 15_000 }); + const clicked = await host.evaluate((el: HTMLElement, name: string) => { + const root = el.shadowRoot; + const button = + root?.querySelector(`[data-sctest-suite="${name}"]`) ?? + root?.querySelector('[data-sctest="run-all"]'); + button?.click(); + return Boolean(button); + }, suiteName); + expect(clicked, `No panel run button found for suite: ${suiteName}`).toBe(true); + }; +} + test.describe("GM API", () => { let gmApiMockServer: GMApiMockServer; @@ -305,6 +613,97 @@ test.describe("GM API", () => { expect(inlineRan, "The local target page must enforce script-src CSP").toBe(false); }); + test("SCTest panel keeps its layout when the page blocks inline styles", async ({ context }) => { + const page = await context.newPage(); + const violations: string[] = []; + page.on("console", (message) => { + if (message.text().includes("Content Security Policy")) violations.push(message.text()); + }); + await page.goto(`${gmApiMockServer.cspOrigin}/?sctest_panel_csp`, { waitUntil: "domcontentloaded" }); + await page.evaluate(fs.readFileSync(path.resolve(__dirname, "../example/tests/lib/sctest.js"), "utf8")); + + const result = await page.evaluate(async () => { + const testRun = (window as typeof window & { SCTest: SCTestBrowserApi }).SCTest.create({ + name: "CSP panel", + 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") + ); + }); + await testRun.run(); + const panel = document.getElementById("sctest-panel-host")?.shadowRoot?.querySelector(".sc-panel"); + const styles = panel && getComputedStyle(panel); + return { + position: styles?.position, + width: styles?.width, + adoptedStyleSheets: + panel?.getRootNode() instanceof ShadowRoot ? panel.getRootNode().adoptedStyleSheets.length : 0, + }; + }); + + expect(result).toEqual({ position: "fixed", width: "440px", adoptedStyleSheets: 1 }); + expect(violations).toEqual([]); + + const host = page.locator("#sctest-panel-host"); + const visibleCases = () => + host + .locator('[data-sctest="case-row"]') + .evaluateAll((rows) => + rows.filter((row) => getComputedStyle(row).display !== "none").map((row) => row.textContent) + ); + await host.locator('[data-sctest="filter-fail"]').click(); + expect(await visibleCases()).toEqual([expect.stringContaining("failing case")]); + await host.locator('[data-sctest="filter-skip"]').click(); + expect(await visibleCases()).toEqual([expect.stringContaining("skipped case")]); + await host.locator('[data-sctest="filter-all"]').click(); + expect(await visibleCases()).toHaveLength(3); + + const suiteGroupHidden = () => + host.locator('[data-sctest="suite-row"]').evaluate((row) => (row.nextElementSibling as HTMLElement).hidden); + await host.locator('[data-sctest="collapse-all"]').click(); + expect(await suiteGroupHidden()).toBe(true); + await host.locator('[data-sctest="collapse-all"]').click(); + expect(await suiteGroupHidden()).toBe(false); + + await page.evaluate(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: (text: string) => ( + ((window as typeof window & { copiedJson?: string }).copiedJson = text), + Promise.resolve() + ), + }, + }); + }); + await host.locator('[data-sctest="export-json"]').click(); + const copiedReport = await page.evaluate( + () => + JSON.parse((window as typeof window & { copiedJson: string }).copiedJson) as { name: string; cases: unknown[] } + ); + expect(copiedReport.name).toBe("CSP panel"); + expect(copiedReport.cases).toHaveLength(3); + + const panel = host.locator(".sc-panel"); + const grip = host.locator('[data-sctest="drag-handle"]'); + const before = await panel.boundingBox(); + const gripBox = await grip.boundingBox(); + expect(before).not.toBeNull(); + expect(gripBox).not.toBeNull(); + await page.mouse.move(gripBox!.x + gripBox!.width / 2, gripBox!.y + gripBox!.height / 2); + await page.mouse.down(); + await page.mouse.move(gripBox!.x - 40, gripBox!.y - 30, { steps: 4 }); + await page.mouse.up(); + const after = await panel.boundingBox(); + expect(after!.x).toBeLessThan(before!.x); + expect(after!.y).toBeLessThan(before!.y); + await page.close(); + }); + test("GM_ sync API tests (gm_api_sync_test.js)", async ({ context, extensionId }) => { const { passed, failed, logs } = await runTestScript( context, @@ -312,7 +711,7 @@ test.describe("GM API", () => { "gm_api_sync_test.js", `${gmApiMockServer.cspOrigin}/?gm_api_sync`, 90_000, - { patchCode } + { patchCode, requireOrigin: gmApiMockServer.origin } ); console.log(`[gm_api_sync_test] passed=${passed}, failed=${failed}`); @@ -330,7 +729,7 @@ test.describe("GM API", () => { "gm_api_async_test.js", `${gmApiMockServer.cspOrigin}/?gm_api_async`, 90_000, - { patchCode } + { patchCode, requireOrigin: gmApiMockServer.origin } ); console.log(`[gm_api_async_test] passed=${passed}, failed=${failed}`); @@ -347,7 +746,8 @@ test.describe("GM API", () => { extensionId, "inject_content_test.js", `${gmApiMockServer.cspOrigin}/?inject_content`, - 60_000 + 60_000, + { requireOrigin: gmApiMockServer.origin } ); console.log(`[inject_content_test] passed=${passed}, failed=${failed}`); @@ -364,7 +764,8 @@ test.describe("GM API", () => { extensionId, "early_inject_page_test.js", `${gmApiMockServer.cspOrigin}/?early_inject_page`, - 60_000 + 60_000, + { requireOrigin: gmApiMockServer.origin } ); if (failed !== 0) console.log("[early_inject_page_test] logs:", logs.join("\n")); @@ -378,7 +779,8 @@ test.describe("GM API", () => { extensionId, "early_inject_content_test.js", `${gmApiMockServer.cspOrigin}/?early_inject_content`, - 60_000 + 60_000, + { requireOrigin: gmApiMockServer.origin } ); if (failed !== 0) console.log("[early_inject_content_test] logs:", logs.join("\n")); @@ -392,7 +794,8 @@ test.describe("GM API", () => { extensionId, "unwrap_e2e_test.js", `${gmApiMockServer.cspOrigin}/?unwrap_e2e_test`, - 60_000 + 60_000, + { requireOrigin: gmApiMockServer.origin } ); console.log(`[unwrap_e2e_test] passed=${passed}, failed=${failed}`); @@ -410,7 +813,7 @@ test.describe("GM API", () => { "window_message_test.js", `${gmApiMockServer.cspOrigin}/?WINDOW_MESSAGE_TEST_SC`, 8_000, - { patchCode } + { patchCode, requireOrigin: gmApiMockServer.origin } ); console.log(`[window_message_test] passed=${passed}, failed=${failed}`); @@ -427,7 +830,8 @@ test.describe("GM API", () => { extensionId, "sandbox_test.js", `${gmApiMockServer.cspOrigin}/?SANDBOX_TEST_SC`, - 8_000 + 8_000, + { requireOrigin: gmApiMockServer.origin } ); console.log(`[sandbox_test] passed=${passed}, failed=${failed}`); @@ -437,4 +841,68 @@ test.describe("GM API", () => { expect(failed, "Some sandbox tests failed").toBe(0); expect(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( + context, + extensionId, + "gm_xhr_redirect_test.js", + `${gmApiMockServer.origin}/?GM_XHR_REDIRECT_TEST_SC`, + 90_000, + { patchCode, requireOrigin: gmApiMockServer.origin } + ); + + console.log(`[gm_xhr_redirect_test] passed=${passed}, failed=${failed}`); + if (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); + }); + + test("GM_download tests (gm_download_test.js)", async ({ context, extensionId }) => { + const { passed, failed, logs } = await runTestScript( + context, + extensionId, + "gm_download_test.js", + `${gmApiMockServer.origin}/?GM_DOWNLOAD_TEST_SC`, + 120_000, + { + patchCode, + requireOrigin: gmApiMockServer.origin, + // 两个 suite 都是 auto:false;统一入口会执行自动用例,itManual 仍保持待人工确认。 + beforeCollect: clickSuiteRunButton("GM_download 自动套件"), + } + ); + + console.log(`[gm_download_test] passed=${passed}, failed=${failed}`); + if (failed !== 0) { + console.log("[gm_download_test] logs:", logs.join("\n")); + } + expect(failed, "Some GM_download tests failed").toBe(0); + expect(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( + context, + extensionId, + "gm_xhr_test.js", + `${gmApiMockServer.origin}/?GM_XHR_TEST_SC`, + // 138 个用例(69 个基础用例 × xhr/fetch 两轮),其中含多个秒级的 delay/drip 端点。 + 180_000, + { + patchCode, + requireOrigin: gmApiMockServer.origin, + beforeCollect: clickSuiteRunButton("GM_xmlhttpRequest"), + } + ); + + console.log(`[gm_xhr_test] passed=${passed}, failed=${failed}`); + if (failed !== 0) { + 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); + }); }); diff --git a/example/tests/early_inject_content_test.js b/example/tests/early_inject_content_test.js index ad28dcfed..174629254 100644 --- a/example/tests/early_inject_content_test.js +++ b/example/tests/early_inject_content_test.js @@ -15,215 +15,139 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== -// 测试辅助函数(支持同步和异步) -async function test(name, fn) { - testResults.total++; - try { - await fn(); - testResults.passed++; - console.log(`%c✓ ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%c✗ ${name}`, "color: red;", error); - return false; - } -} - -function testSync(name, fn) { - testResults.total++; - try { - fn(); - testResults.passed++; - console.log(`%c✓ ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%c✗ ${name}`, "color: red;", error); - return false; - } -} - -// assert(expected, actual, message) - 比较两个值是否相等 -function assert(expected, actual, message) { - if (expected !== actual) { - const valueInfo = `期望 ${JSON.stringify(expected)}, 实际 ${JSON.stringify(actual)}`; - const error = message ? `${message} - ${valueInfo}` : `断言失败: ${valueInfo}`; - throw new Error(error); - } -} - -// assertTrue(condition, message) - 断言条件为真 -function assertTrue(condition, message) { - if (!condition) { - throw new Error(message || "断言失败: 期望条件为真"); - } -} - -console.log("%c=== Content环境 GM API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); - -let testResults = { - passed: 0, - failed: 0, - total: 0, -}; +// 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" }); // 同步测试 - -// ============ GM_addElement/GM_addStyle 测试 ============ -console.log("\n%c--- DOM操作 API 测试 ---", "color: orange; font-weight: bold;"); - -testSync("GM_addElement", () => { - const element = GM_addElement("div", { - textContent: "GM_addElement测试元素", - style: "display:none;", - id: "gm-test-element", +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); }); - assertTrue(element !== null && element !== undefined, "GM_addElement应该返回元素"); - assert("gm-test-element", element.id, "元素ID应该正确"); - assert("DIV", element.tagName, "元素标签应该是DIV"); - console.log("返回元素:", element); - // 清理测试元素 - element.parentNode.removeChild(element); -}); -testSync("GM_addStyle", () => { - const styleElement = GM_addStyle(` + it("GM_addStyle", () => { + const styleElement = GM_addStyle(` .gm-style-test { color: #10b981 !important; } `); - assertTrue(styleElement !== null && styleElement !== undefined, "GM_addStyle应该返回样式元素"); - assertTrue(styleElement.tagName === "STYLE" || styleElement.sheet, "应该返回STYLE元素或样式对象"); - console.log("返回样式元素:", styleElement); - // 清理测试样式 - styleElement.parentNode.removeChild(styleElement); + expect(styleElement !== null && styleElement !== undefined).toBeTruthy(); + expect(styleElement.tagName === "STYLE" || styleElement.sheet).toBeTruthy(); + // 清理测试样式 + styleElement.parentNode.removeChild(styleElement); + }); }); (async function () { "use strict"; - // ============ 早期脚本环境检查 ============ - console.log("\n%c--- 早期脚本环境检查 ---", "color: orange; font-weight: bold;"); - - await test("检查 document.head 不存在", () => { - console.log("document.head 存在:", !!document.head); - console.log("document.head 值:", document.head); - // 早期脚本运行时 document.head 应该不存在 - assertTrue(document.head === null || document.head === undefined, "早期脚本运行时 document.head 应该不存在"); - }); + 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(); + }); - await test("检查 document.body 不存在", () => { - console.log("document.body 存在:", !!document.body); - console.log("document.body 值:", document.body); - // 早期脚本运行时 document.body 应该不存在 - assertTrue(document.body === null || document.body === undefined, "早期脚本运行时 document.body 应该不存在"); - }); + 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(); + }); - await test("检查可用的DOM节点应该是HTML元素", () => { - const firstElement = document.querySelector("*"); - console.log("querySelector('*') 找到的第一个元素:", firstElement?.tagName); - assertTrue(firstElement !== null, "应该能找到第一个DOM节点"); - assert("HTML", firstElement.tagName, "早期脚本运行时,第一个可用节点应该是HTML元素"); - assert("", firstElement.innerHTML, "HTML元素内容应该为空"); - console.log("节点详情:", { - tagName: firstElement.tagName, - childNodes: firstElement.childNodes.length, - children: firstElement.children.length, - innerHTML: firstElement.innerHTML, + 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(""); }); }); - // ============ CSP绕过测试 ============ - console.log("\n%c--- CSP绕过测试 ---", "color: orange; font-weight: bold;"); - - await test("CSP绕过 - 内联脚本", () => { - const script = document.createElement("script"); - script.textContent = 'console.log("Content环境绕过CSP测试");'; - // 早期脚本运行时 document.head 和 document.body 不存在 - // 使用 querySelector("*") 查找第一个可用的元素(应该是HTML元素)进行注入 - let node = document.querySelector("*"); - assertTrue(node !== null, "应该能找到可注入的DOM节点"); - node.appendChild(script); - assertTrue(script.parentNode === node, "脚本应该成功插入到找到的节点中"); - assert("HTML", node.tagName, "注入节点应该是HTML元素"); - console.log("脚本注入到:", node.tagName); + 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"); + }); }); - // ============ GM_log 测试 ============ - console.log("\n%c--- GM_log 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_log", () => { - GM_log("测试日志输出", "info", { type: "test", value: 123 }); - // GM_log本身不返回值,只要不抛出异常就算成功 - assertTrue(true, "GM_log应该能正常输出"); + describe("GM_log 测试", () => { + it("GM_log", () => { + GM_log("测试日志输出", "info", { type: "test", value: 123 }); + // GM_log本身不返回值,只要不抛出异常就算成功 + expect(true).toBeTruthy(); + }); }); - // ============ GM_info 测试 ============ - console.log("\n%c--- GM_info 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_info", () => { - assertTrue(typeof GM_info === "object", "GM_info应该是对象"); - assertTrue(!!GM_info.script, "GM_info.script应该存在"); - assertTrue(!!GM_info.script.name, "GM_info.script.name应该存在"); - console.log("脚本信息:", GM_info.script.name); + describe("GM_info 测试", () => { + it("GM_info", () => { + expect(typeof GM_info === "object").toBeTruthy(); + expect(!!GM_info.script).toBeTruthy(); + expect(!!GM_info.script.name).toBeTruthy(); + }); }); - // ============ GM 存储 API 测试 ============ - console.log("\n%c--- GM 存储 API 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_setValue - 字符串", async () => { - await GM.setValue("test_key", "content环境测试值"); - const value = GM_getValue("test_key"); - assert("content环境测试值", value, "应该正确保存和读取字符串"); - }); + describe("GM 存储 API 测试", () => { + it("GM_setValue - 字符串", async () => { + await GM.setValue("test_key", "content环境测试值"); + const value = GM_getValue("test_key"); + expect(value).toBe("content环境测试值"); + }); - await test("GM_setValue - 数字", () => { - GM_setValue("test_number", 12345); - const value = GM_getValue("test_number"); - assert(12345, value, "应该正确保存和读取数字"); - }); + it("GM_setValue - 数字", () => { + GM_setValue("test_number", 12345); + const value = GM_getValue("test_number"); + expect(value).toBe(12345); + }); - await test("GM_setValue - 对象", () => { - const obj = { name: "ScriptCat", type: "content" }; - GM_setValue("test_object", obj); - const value = GM_getValue("test_object", {}); - assert("ScriptCat", value.name, "对象的name属性应该正确"); - assert("content", value.type, "对象的type属性应该正确"); - }); + 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"); + }); - await test("GM_getValue - 默认值", () => { - const value = GM_getValue("non_existent_key", "默认值"); - assert("默认值", value, "不存在的键应该返回默认值"); - }); + it("GM_getValue - 默认值", () => { + const value = GM_getValue("non_existent_key", "默认值"); + expect(value).toBe("默认值"); + }); - await test("GM_listValues", () => { - const keys = GM_listValues(); - assertTrue(Array.isArray(keys), "GM_listValues应该返回数组"); - assertTrue(keys.length >= 3, "应该至少有3个存储键"); - console.log("存储的键:", keys); - }); + it("GM_listValues", () => { + const keys = GM_listValues(); + expect(Array.isArray(keys)).toBeTruthy(); + expect(keys.length >= 3).toBeTruthy(); + }); - await test("GM_deleteValue", () => { - GM_setValue("test_delete", "to_be_deleted"); - assert("to_be_deleted", GM_getValue("test_delete"), "值应该存在"); - GM_deleteValue("test_delete"); - assert(null, GM_getValue("test_delete", 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); + }); }); - // ============ 输出测试结果 ============ - console.log("\n%c=== 测试完成 ===", "color: blue; font-size: 16px; font-weight: bold;"); - console.log( - `%c总计: ${testResults.total} | 通过: ${testResults.passed} | 失败: ${testResults.failed}`, - testResults.failed === 0 ? "color: green; font-weight: bold;" : "color: red; font-weight: bold;" - ); - - if (testResults.failed === 0) { - console.log("%c🎉 所有测试通过!", "color: green; font-size: 14px; font-weight: bold;"); - } else { - console.log("%c⚠️ 部分测试失败,请检查上面的错误信息", "color: red; font-size: 14px; font-weight: bold;"); - } + await run(); })(); diff --git a/example/tests/early_inject_page_test.js b/example/tests/early_inject_page_test.js index 9d6e9d0bb..44a6a142b 100644 --- a/example/tests/early_inject_page_test.js +++ b/example/tests/early_inject_page_test.js @@ -14,242 +14,169 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== -// 测试辅助函数(支持同步和异步) -async function test(name, fn) { - testResults.total++; - try { - await fn(); - testResults.passed++; - console.log(`%c✓ ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%c✗ ${name}`, "color: red;", error); - return false; - } -} - -function testSync(name, fn) { - testResults.total++; - try { - fn(); - testResults.passed++; - console.log(`%c✓ ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%c✗ ${name}`, "color: red;", error); - return false; - } -} - -// assert(expected, actual, message) - 比较两个值是否相等 -function assert(expected, actual, message) { - if (expected !== actual) { - const valueInfo = `期望 ${JSON.stringify(expected)}, 实际 ${JSON.stringify(actual)}`; - const error = message ? `${message} - ${valueInfo}` : `断言失败: ${valueInfo}`; - throw new Error(error); - } -} - -// assertTrue(condition, message) - 断言条件为真 -function assertTrue(condition, message) { - if (!condition) { - throw new Error(message || "断言失败: 期望条件为真"); - } -} - -console.log("%c=== 早期脚本 GM API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); - -let testResults = { - passed: 0, - failed: 0, - total: 0, -}; - -// ============ GM_addElement/GM_addStyle 测试 ============ -console.log("\n%c--- DOM操作 API 测试 ---", "color: orange; font-weight: bold;"); - -testSync("GM_addElement", () => { - const element = GM_addElement("div", { - textContent: "GM_addElement测试元素", - style: "display:none;", - id: "gm-test-element", +// 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" }); + +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); }); - assertTrue(element !== null && element !== undefined, "GM_addElement应该返回元素"); - assert("gm-test-element", element.id, "元素ID应该正确"); - assert("DIV", element.tagName, "元素标签应该是DIV"); - console.log("返回元素:", element); - // 清理测试元素 - element.parentNode.removeChild(element); -}); -testSync("GM_addStyle", () => { - const styleElement = GM_addStyle(` + it("GM_addStyle", () => { + const styleElement = GM_addStyle(` .gm-style-test { color: #10b981 !important; } `); - assertTrue(styleElement !== null && styleElement !== undefined, "GM_addStyle应该返回样式元素"); - assertTrue(styleElement.tagName === "STYLE" || styleElement.sheet, "应该返回STYLE元素或样式对象"); - console.log("返回样式元素:", styleElement); - // 清理测试样式 - styleElement.parentNode.removeChild(styleElement); + expect(styleElement !== null && styleElement !== undefined).toBeTruthy(); + expect(styleElement.tagName === "STYLE" || styleElement.sheet).toBeTruthy(); + // 清理测试样式 + styleElement.parentNode.removeChild(styleElement); + }); }); (async function () { "use strict"; - // ============ 早期脚本环境检查 ============ - console.log("\n%c--- 早期脚本环境检查 ---", "color: orange; font-weight: bold;"); - - await test("检查 document.head 不存在", () => { - console.log("document.head 存在:", !!document.head); - console.log("document.head 值:", document.head); - // 早期脚本运行时 document.head 应该不存在 - assertTrue(document.head === null || document.head === undefined, "早期脚本运行时 document.head 应该不存在"); - }); - - await test("检查 document.body 不存在", () => { - console.log("document.body 存在:", !!document.body); - console.log("document.body 值:", document.body); - // 早期脚本运行时 document.body 应该不存在 - assertTrue(document.body === null || document.body === undefined, "早期脚本运行时 document.body 应该不存在"); - }); + 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(); + }); - await test("检查可用的DOM节点应该是HTML元素", () => { - const firstElement = document.querySelector("*"); - console.log("querySelector('*') 找到的第一个元素:", firstElement?.tagName); - assertTrue(firstElement !== null, "应该能找到第一个DOM节点"); - assert("HTML", firstElement.tagName, "早期脚本运行时,第一个可用节点应该是HTML元素"); - assert("", firstElement.innerHTML, "HTML元素内容应该为空"); - console.log("节点详情:", { - tagName: firstElement.tagName, - childNodes: firstElement.childNodes.length, - children: firstElement.children.length, - innerHTML: firstElement.innerHTML, + 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(); }); - }); - await test("检查页面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,等待异步加载结果..."); + 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(""); }); - // 验证检测结果 - if (loadResult.blocked) { - console.log("%c✓ 页面存在CSP策略限制(符合预期)", "color: #10b981; font-weight: bold;"); - assertTrue(true, "CSP正确阻止了外部script加载"); - } else if (loadResult.success) { - console.log("%c✗ 页面无CSP限制或已允许该资源(不符合预期)", "color: #ef4444; font-weight: bold;"); - assertTrue(false, "期望CSP阻止外部script,但实际加载成功"); - } + 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,等待异步加载结果..."); + }); + + // 验证检测结果 + 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(); + } + }); }); - // ============ GM_log 测试 ============ - console.log("\n%c--- GM_log 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_log", () => { - GM_log("测试日志输出", "info", { type: "test", value: 123 }); - // GM_log本身不返回值,只要不抛出异常就算成功 - assertTrue(true, "GM_log应该能正常输出"); + describe("GM_log 测试", () => { + it("GM_log", () => { + GM_log("测试日志输出", "info", { type: "test", value: 123 }); + // GM_log本身不返回值,只要不抛出异常就算成功 + expect(true).toBeTruthy(); + }); }); - // ============ GM_info 测试 ============ - console.log("\n%c--- GM_info 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_info", () => { - assertTrue(typeof GM_info === "object", "GM_info应该是对象"); - assertTrue(!!GM_info.script, "GM_info.script应该存在"); - assertTrue(!!GM_info.script.name, "GM_info.script.name应该存在"); - console.log("脚本信息:", GM_info.script.name); + describe("GM_info 测试", () => { + it("GM_info", () => { + expect(typeof GM_info === "object").toBeTruthy(); + expect(!!GM_info.script).toBeTruthy(); + expect(!!GM_info.script.name).toBeTruthy(); + }); }); - // ============ GM 存储 API 测试 ============ - console.log("\n%c--- GM 存储 API 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_setValue - 字符串", async () => { - await GM.setValue("test_key", "早期脚本测试值"); - const value = GM_getValue("test_key"); - assert("早期脚本测试值", value, "应该正确保存和读取字符串"); - }); + describe("GM 存储 API 测试", () => { + it("GM_setValue - 字符串", async () => { + await GM.setValue("test_key", "早期脚本测试值"); + const value = GM_getValue("test_key"); + expect(value).toBe("早期脚本测试值"); + }); - await test("GM_setValue - 数字", () => { - GM_setValue("test_number", 12345); - const value = GM_getValue("test_number"); - assert(12345, value, "应该正确保存和读取数字"); - }); + it("GM_setValue - 数字", () => { + GM_setValue("test_number", 12345); + const value = GM_getValue("test_number"); + expect(value).toBe(12345); + }); - await test("GM_setValue - 对象", () => { - const obj = { name: "ScriptCat", type: "early" }; - GM_setValue("test_object", obj); - const value = GM_getValue("test_object", {}); - assert("ScriptCat", value.name, "对象的name属性应该正确"); - assert("early", value.type, "对象的type属性应该正确"); - }); + 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"); + }); - await test("GM_getValue - 默认值", () => { - const value = GM_getValue("non_existent_key", "默认值"); - assert("默认值", value, "不存在的键应该返回默认值"); - }); + it("GM_getValue - 默认值", () => { + const value = GM_getValue("non_existent_key", "默认值"); + expect(value).toBe("默认值"); + }); - await test("GM_listValues", () => { - const keys = GM_listValues(); - assertTrue(Array.isArray(keys), "GM_listValues应该返回数组"); - assertTrue(keys.length >= 3, "应该至少有3个存储键"); - console.log("存储的键:", keys); - }); + it("GM_listValues", () => { + const keys = GM_listValues(); + expect(Array.isArray(keys)).toBeTruthy(); + expect(keys.length >= 3).toBeTruthy(); + }); - await test("GM_deleteValue", () => { - GM_setValue("test_delete", "to_be_deleted"); - assert("to_be_deleted", GM_getValue("test_delete"), "值应该存在"); - GM_deleteValue("test_delete"); - assert(null, GM_getValue("test_delete", 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); + }); }); - // ============ 输出测试结果 ============ - console.log("\n%c=== 测试完成 ===", "color: blue; font-size: 16px; font-weight: bold;"); - console.log( - `%c总计: ${testResults.total} | 通过: ${testResults.passed} | 失败: ${testResults.failed}`, - testResults.failed === 0 ? "color: green; font-weight: bold;" : "color: red; font-weight: bold;" - ); - - if (testResults.failed === 0) { - console.log("%c🎉 所有测试通过!", "color: green; font-size: 14px; font-weight: bold;"); - } else { - console.log("%c⚠️ 部分测试失败,请检查上面的错误信息", "color: red; font-size: 14px; font-weight: bold;"); - } + await run(); })(); diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index b51263a0f..e04473708 100644 --- a/example/tests/gm_api_async_test.js +++ b/example/tests/gm_api_async_test.js @@ -23,6 +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 // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbingo.org // @connect example.com @@ -32,528 +33,298 @@ (async function () { "use strict"; - console.log("%c=== ScriptCat GM.* API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); - - let testResults = { - passed: 0, - failed: 0, - total: 0, - }; - - // 测试辅助函数 - async function testAsync(name, fn) { - testResults.total++; - try { - await fn(); - testResults.passed++; - console.log(`%c✓ ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%c✗ ${name}`, "color: red;", error); - return false; - } - } - - // assert 函数 - function assert(expected, actual, message) { - if (expected !== actual) { - const valueInfo = `期望 ${JSON.stringify(expected)}, 实际 ${JSON.stringify(actual)}`; - const error = message ? `${message} - ${valueInfo}` : `断言失败: ${valueInfo}`; - throw new Error(error); - } - } - - // ============ GM.info 测试 ============ - console.log("\n%c--- GM.info 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("GM.info 存在", async () => { - assert("object", typeof GM.info, "GM.info 应该是一个对象"); - assert(true, !!GM.info.script, "GM.info.script 应该存在"); - assert(true, !!GM.info.scriptMetaStr, "GM.info.scriptMetaStr 应该存在"); - console.log("GM.info:", GM.info); - }); - - // ============ GM 存储 API 测试 ============ - console.log("\n%c--- GM 存储 API 测试 ---", "color: orange; font-weight: bold;"); + const { describe, it, expect, run } = SCTest.create({ name: "GM.* API 完整测试(异步)" }); - await testAsync("GM.setValue - 字符串", async () => { - await GM.setValue("test_string", "Hello ScriptCat Async"); - const value = await GM.getValue("test_string"); - assert("Hello ScriptCat Async", value, "GM.getValue 应该返回正确的字符串值"); + describe("GM.info", () => { + it("GM.info 存在", async () => { + expect(GM.info).toBeTypeOf("object"); + expect(GM.info.script).toBeTruthy(); + expect(GM.info.scriptMetaStr).toBeTruthy(); + }); }); - await testAsync("GM.setValue - 数字", async () => { - await GM.setValue("test_number", 42); - const value = await GM.getValue("test_number"); - assert(42, value, "GM.getValue 应该返回正确的数字值"); - }); + 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"); + }); - await testAsync("GM.setValue - 布尔值", async () => { - await GM.setValue("test_boolean", true); - const value = await GM.getValue("test_boolean"); - assert(true, value, "GM.getValue 应该返回正确的布尔值"); - }); + it("GM.setValue - 数字", async () => { + await GM.setValue("test_number", 42); + const value = await GM.getValue("test_number"); + expect(value).toBe(42); + }); - await testAsync("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"); - assert("object", typeof value, "应该返回对象"); - assert(obj.name, value.name, "name 属性应该相等"); - assert(obj.version, value.version, "version 属性应该相等"); - assert(JSON.stringify(obj.features), JSON.stringify(value.features), "features 数组应该相等"); - }); + it("GM.setValue - 布尔值", async () => { + await GM.setValue("test_boolean", true); + const value = await GM.getValue("test_boolean"); + expect(value).toBe(true); + }); - await testAsync("GM.setValue - 数组", async () => { - const arr = [1, 2, 3, "test", { key: "value" }]; - await GM.setValue("test_array", arr); - const value = await GM.getValue("test_array"); - assert(true, Array.isArray(value), "应该返回数组"); - assert(arr.length, value.length, "数组长度应该相等"); - assert(arr[0], value[0], "第1个元素应该相等"); - assert(arr[3], value[3], "第4个元素应该相等"); - assert(arr[4].key, value[4].key, "对象元素的属性应该相等"); - }); + 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)); + }); - await testAsync("GM.getValue - 默认值", async () => { - const value = await GM.getValue("non_existent_key", "default_value"); - assert("default_value", value, "不存在的键应该返回默认值"); - }); + 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); + }); - await testAsync("GM.listValues", async () => { - const values = await GM.listValues(); - assert(true, Array.isArray(values), "GM.listValues 应该返回数组"); - assert(true, values.includes("test_string"), "应该包含已存储的键"); - console.log("存储的键:", values); - }); + it("GM.getValue - 默认值", async () => { + const value = await GM.getValue("non_existent_key", "default_value"); + expect(value).toBe("default_value"); + }); - await testAsync("GM.deleteValue", async () => { - await GM.setValue("test_delete", "to be deleted"); - assert("to be deleted", await GM.getValue("test_delete"), "值应该存在"); - await GM.deleteValue("test_delete"); - assert("not_found", await GM.getValue("test_delete", "not_found"), "值应该被删除"); - }); + it("GM.listValues", async () => { + const values = await GM.listValues(); + expect(Array.isArray(values)).toBeTruthy(); + expect(values.includes("test_string")).toBeTruthy(); + }); - // ============ GM.addStyle 测试 ============ - console.log("\n%c--- GM 样式 API 测试 ---", "color: orange; font-weight: bold;"); + 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"); + }); + }); - await testAsync("GM.addStyle - CSS字符串", async () => { - const css = ` + describe("GM 样式 API", () => { + it("GM.addStyle - CSS字符串", async () => { + const css = ` .scriptcat-test-async { color: blue; font-weight: bold; } `; - const element = await GM.addStyle(css); - assert(true, element && element.tagName === "STYLE", "应该返回 style 元素"); - console.log("添加的样式元素:", element); + const element = await GM.addStyle(css); + expect(element && element.tagName === "STYLE").toBeTruthy(); + }); }); - // ============ GM.addElement 测试 ============ - await testAsync("GM.addElement - 创建元素", async () => { - assert("function", typeof GM.addElement, "GM.addElement 应该是函数"); + 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;", - }); - assert(true, div && div.tagName === "DIV", "应该返回 div 元素"); - console.log("添加的元素:", div); + 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); + // 3秒后移除 + setTimeout(() => div.remove(), 3000); + }); }); - // ============ GM.getResourceText/Url 测试 ============ - console.log("\n%c--- GM 资源 API 测试 ---", "color: orange; font-weight: bold;"); + describe("GM 资源 API", () => { + it("GM.getResourceText", async () => { + expect(GM.getResourceText).toBeTypeOf("function"); - await testAsync("GM.getResourceText", async () => { - assert("function", typeof GM.getResourceText, "GM.getResourceText 应该是函数"); - - const css = await GM.getResourceText("testCSS"); - assert("string", typeof css, "应该返回字符串"); - assert(163870, css.length, "资源内容长度应该是 163870"); - console.log("资源文本长度:", css.length); - }); + const css = await GM.getResourceText("testCSS"); + expect(css).toBeTypeOf("string"); + expect(css.length).toBe(163870); + }); - await testAsync("GM.getResourceUrl", async () => { - assert("function", typeof GM.getResourceUrl, "GM.getResourceUrl 应该是函数"); + it("GM.getResourceUrl", async () => { + expect(GM.getResourceUrl).toBeTypeOf("function"); - const url = await GM.getResourceUrl("testCSS"); - assert("string", typeof url, "应该返回字符串"); - assert(true, url.startsWith("data:") || url.startsWith("blob:"), "应该返回 data URL 或 blob URL"); - console.log("资源 URL:", url.substring(0, 50) + "..."); + const url = await GM.getResourceUrl("testCSS"); + expect(url).toBeTypeOf("string"); + expect(url.startsWith("data:") || url.startsWith("blob:")).toBeTruthy(); + }); }); - // ============ GM.xmlHttpRequest 测试 ============ - console.log("\n%c--- GM 网络请求 API 测试 ---", "color: orange; font-weight: bold;"); + describe("GM 网络请求 API", () => { + it("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("请求超时")); + }, + }); + }); + }); - await testAsync("GM.xmlHttpRequest - GET 请求", async () => { - return new Promise((resolve, reject) => { - GM.xmlHttpRequest({ + it("GM.xmlHttpRequest - 返回控制对象", async () => { + const controller = GM.xmlHttpRequest({ method: "GET", url: "https://httpbingo.org/get", timeout: 10000, - onload: (response) => { - try { - assert(200, response.status, `请求状态码应该是 200`); - assert(true, !!response.responseText, "响应内容不应为空"); - const data = JSON.parse(response.responseText); - assert("object", typeof data, "应该返回有效的 JSON 对象"); - assert("https://httpbingo.org/get", data.url, "响应应该包含 url 字段"); - console.log("httpbingo 响应信息:", data.url); - resolve(); - } catch (error) { - reject(error); - } - }, - onerror: (error) => { - reject(new Error("请求失败: " + error)); - }, - ontimeout: () => { - reject(new Error("请求超时")); - }, + onload: () => {}, + onerror: () => {}, }); + expect(controller).toBeTypeOf("object"); + expect(controller.abort).toBeTypeOf("function"); + controller.abort(); }); }); - await testAsync("GM.xmlHttpRequest - 返回控制对象", async () => { - const controller = GM.xmlHttpRequest({ - method: "GET", - url: "https://httpbingo.org/get", - timeout: 10000, - onload: () => {}, - onerror: () => {}, - }); - assert("object", typeof controller, "应该返回控制对象"); - assert("function", typeof controller.abort, "控制对象应该有 abort 方法"); - console.log("XHR 控制对象:", controller); - controller.abort(); - }); - - // ============ GM.notification 测试 ============ - console.log("\n%c--- GM 通知 API 测试 ---", "color: orange; font-weight: bold;"); + describe("GM 通知 API", () => { + it("GM.notification - Promise 版本", async () => { + expect(GM.notification).toBeTypeOf("function"); - await testAsync("GM.notification - Promise 版本", async () => { - assert("function", typeof GM.notification, "GM.notification 应该是函数"); + const notificationPromise = GM.notification({ + text: "ScriptCat GM.* API 测试通知", + title: "ScriptCat 异步测试", + image: "https://scriptcat.org/logo.png", + onclick: () => { + console.log("通知被点击"); + }, + }); - 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("通知已发送(请检查系统通知)"); + } }); - - // GM.notification 可能返回 Promise 或控制对象 - if (notificationPromise && typeof notificationPromise.then === "function") { - await notificationPromise; - console.log("通知已发送(Promise 已完成)"); - } else { - console.log("通知已发送(请检查系统通知)"); - } }); - // ============ GM.setClipboard 测试 ============ - console.log("\n%c--- GM 剪贴板 API 测试 ---", "color: orange; font-weight: bold;"); - - await testAsync("GM.setClipboard", async () => { - assert("function", typeof GM.setClipboard, "GM.setClipboard 应该是函数"); - - await GM.setClipboard("ScriptCat GM.* API 测试文本 - " + new Date().toLocaleString()); - console.log("文本已复制到剪贴板(可以尝试粘贴验证)"); - }); - - // ============ GM.openInTab 测试 ============ - console.log("\n%c--- GM 标签页 API 测试 ---", "color: orange; font-weight: bold;"); - - await testAsync("GM.openInTab (不执行)", async () => { - // 不实际打开标签页,只测试函数是否存在 - assert("function", typeof GM.openInTab, "GM.openInTab 应该是函数"); - console.log("GM.openInTab 可用 (未实际打开标签页)"); - }); + describe("GM 剪贴板 API", () => { + it("GM.setClipboard", async () => { + expect(GM.setClipboard).toBeTypeOf("function"); - // ============ GM.registerMenuCommand 测试 ============ - console.log("\n%c--- GM 菜单 API 测试 ---", "color: orange; font-weight: bold;"); - - await testAsync("GM.registerMenuCommand", async () => { - const menuId = await GM.registerMenuCommand("ScriptCat 异步测试菜单", () => { - alert("异步测试菜单被点击!"); + await GM.setClipboard("ScriptCat GM.* API 测试文本 - " + new Date().toLocaleString()); + console.log("文本已复制到剪贴板(可以尝试粘贴验证)"); }); - assert(true, menuId !== undefined, "应该返回菜单ID"); - console.log("菜单已注册,ID:", menuId); - }); - - // ============ GM.cookie 测试 ============ - console.log("\n%c--- GM.cookie API 测试 ---", "color: orange; font-weight: bold;"); - - await testAsync("GM.cookie 函数存在", async () => { - assert("function", typeof GM.cookie, "GM.cookie 应该是一个函数"); - console.log("GM.cookie API 可用"); }); - await testAsync("GM.cookie.set", async () => { - await GM.cookie.set({ - url: "http://example.com/cookie", - name: "scriptcat_async_test1", - value: "async_test_value_1", + describe("GM 标签页 API", () => { + it("GM.openInTab (不执行)", async () => { + // 不实际打开标签页,只测试函数是否存在 + expect(GM.openInTab).toBeTypeOf("function"); }); - console.log("Cookie 已设置: scriptcat_async_test1 @ example.com"); }); - await testAsync("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", + describe("GM 菜单 API", () => { + it("GM.registerMenuCommand", async () => { + const menuId = await GM.registerMenuCommand("ScriptCat 异步测试菜单", () => { + alert("异步测试菜单被点击!"); + }); + expect(menuId !== undefined).toBeTruthy(); }); - console.log("Cookie 已设置: scriptcat_async_test2 @ .example.com/path"); }); - await testAsync("GM.cookie.list (by domain)", async () => { - const cookies = await GM.cookie.list({ - domain: "example.com", + describe("GM Cookie API", () => { + it("GM.cookie 函数存在", async () => { + expect(GM.cookie).toBeTypeOf("function"); }); - assert(true, Array.isArray(cookies), "应该返回数组"); - assert(true, cookies.length >= 1, "应该至少有一个 cookie"); - console.log("列出 example.com 的 cookies:", cookies.length, "个"); - console.log("示例 Cookie:", cookies[0]); - }); - await testAsync("GM.cookie.list (by url)", async () => { - const cookies = await GM.cookie.list({ - url: "http://example.com/cookie", + it("GM.cookie.set", async () => { + await GM.cookie.set({ + url: "http://example.com/cookie", + name: "scriptcat_async_test1", + value: "async_test_value_1", + }); }); - assert(true, Array.isArray(cookies), "应该返回数组"); - console.log("通过 URL 列出的 cookies:", cookies.length, "个"); - }); - await testAsync("GM.cookie.delete", async () => { - await GM.cookie.delete({ - url: "http://www.example.com/path", - name: "scriptcat_async_test2", + 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", + }); }); - console.log("Cookie 已删除: scriptcat_async_test2"); - }); - await testAsync("GM.cookie - 验证删除后", async () => { - const cookies = await GM.cookie.list({ - domain: "example.com", + 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(); }); - const test2Cookie = cookies.find((c) => c.name === "scriptcat_async_test2"); - assert(true, !test2Cookie, "scriptcat_async_test2 应该已被删除"); - console.log("验证:scriptcat_async_test2 已被删除"); - }); - - // 清理所有测试 cookies - await testAsync("清理测试 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) { - console.log("没有需要清理的测试 cookies"); - return; - } - - await Promise.all( - testCookies.map((cookie) => - GM.cookie.delete({ - url: `http://${cookie.domain}${cookie.path}`, - name: cookie.name, - }) - ) - ); - console.log(`已清理 ${testCookies.length} 个测试 cookies`); - }); - // ============ unsafeWindow 测试 ============ - console.log("\n%c--- unsafeWindow 测试 ---", "color: orange; font-weight: bold;"); - - await testAsync("unsafeWindow", async () => { - assert("object", typeof unsafeWindow, "unsafeWindow 应该存在"); - assert(document, unsafeWindow.document, "unsafeWindow.document 应该等于 document"); - console.log("unsafeWindow 可用"); - }); + it("GM.cookie.list (by url)", async () => { + const cookies = await GM.cookie.list({ + url: "http://example.com/cookie", + }); + expect(Array.isArray(cookies)).toBeTruthy(); + }); - // ============ @require 测试 ============ - console.log("\n%c--- @require 测试 ---", "color: orange; font-weight: bold;"); + it("GM.cookie.delete", async () => { + await GM.cookie.delete({ + url: "http://www.example.com/path", + name: "scriptcat_async_test2", + }); + }); - await testAsync("jQuery 加载 (@require)", async () => { - assert("function", typeof jQuery, "jQuery 应该已加载"); - assert("function", typeof $, "$ 应该已加载"); - console.log("jQuery 版本:", jQuery.fn.jquery); - }); + 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(); + }); - // ============ 测试总结 ============ - console.log("\n%c=== 测试结果总结 ===", "color: blue; font-size: 16px; font-weight: bold;"); - console.log(`总测试数: ${testResults.total}`); - console.log(`%c通过: ${testResults.passed}`, "color: green; font-weight: bold;"); - console.log(`%c失败: ${testResults.failed}`, "color: red; font-weight: bold;"); - console.log(`成功率: ${((testResults.passed / testResults.total) * 100).toFixed(2)}%`); - - // 使用 GM.addElement 在页面上显示结果 - const successRate = ((testResults.passed / testResults.total) * 100).toFixed(2); - const bgColor = - testResults.failed === 0 ? "#e8f5e9" : testResults.failed < testResults.total / 2 ? "#fff9c4" : "#ffebee"; - const borderColor = - testResults.failed === 0 ? "#4caf50" : testResults.failed < testResults.total / 2 ? "#ffc107" : "#f44336"; - - const resultContainer = await GM.addElement(document.body, "div", { - style: ` - position: fixed; - bottom: 20px; - right: 20px; - background: ${bgColor}; - border: 3px solid ${borderColor}; - padding: 20px; - border-radius: 10px; - box-shadow: 0 4px 12px rgba(0,0,0,0.2); - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - z-index: 10000; - min-width: 350px; - animation: slideIn 0.5s ease-out; - `, - }); + // 清理所有测试 cookies + it("清理测试 cookies", async () => { + const cookies = await GM.cookie.list({ domain: "example.com" }); + const testCookies = cookies.filter((c) => c.name.startsWith("scriptcat_async_test")); - // 添加动画样式 - await GM.addStyle(` - @keyframes slideIn { - from { - transform: translateX(400px); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; + if (testCookies.length === 0) { + return; } - } - `); - - // 标题 - await GM.addElement(resultContainer, "h3", { - textContent: "🐱 ScriptCat GM.* API 测试结果 (异步版本)", - style: - "margin: 0 0 15px 0; color: #333; font-size: 18px; font-weight: bold; border-bottom: 2px solid " + - borderColor + - "; padding-bottom: 10px;", - }); - - // 测试统计容器 - const statsContainer = await GM.addElement(resultContainer, "div", { - style: "margin-bottom: 15px;", - }); - - // 总测试数 - const totalLine = await GM.addElement(statsContainer, "div", { - style: "margin: 8px 0; font-size: 14px; display: flex; justify-content: space-between;", - }); - await GM.addElement(totalLine, "span", { textContent: "📊 总测试数:" }); - await GM.addElement(totalLine, "strong", { - textContent: testResults.total, - style: "font-size: 16px;", - }); - - // 通过数 - const passedLine = await GM.addElement(statsContainer, "div", { - style: "margin: 8px 0; font-size: 14px; display: flex; justify-content: space-between;", - }); - await GM.addElement(passedLine, "span", { textContent: "✅ 通过:" }); - await GM.addElement(passedLine, "strong", { - textContent: testResults.passed, - style: "color: #4caf50; font-size: 16px;", - }); - - // 失败数 - const failedLine = await GM.addElement(statsContainer, "div", { - style: "margin: 8px 0; font-size: 14px; display: flex; justify-content: space-between;", - }); - await GM.addElement(failedLine, "span", { textContent: "❌ 失败:" }); - await GM.addElement(failedLine, "strong", { - textContent: testResults.failed, - style: "color: #f44336; font-size: 16px;", - }); - // 成功率 - const rateLine = await GM.addElement(statsContainer, "div", { - style: "margin: 8px 0; font-size: 14px; display: flex; justify-content: space-between;", - }); - await GM.addElement(rateLine, "span", { textContent: "📈 成功率:" }); - await GM.addElement(rateLine, "strong", { - textContent: successRate + "%", - style: - "color: " + (successRate >= 90 ? "#4caf50" : successRate >= 70 ? "#ffc107" : "#f44336") + "; font-size: 16px;", + await Promise.all( + testCookies.map((cookie) => + GM.cookie.delete({ + url: `http://${cookie.domain}${cookie.path}`, + name: cookie.name, + }) + ) + ); + }); }); - // 进度条 - const progressBar = await GM.addElement(resultContainer, "div", { - style: "background: #e0e0e0; height: 20px; border-radius: 10px; overflow: hidden; margin: 15px 0;", - }); - await GM.addElement(progressBar, "div", { - style: ` - background: linear-gradient(90deg, #4caf50, #81c784); - height: 100%; - width: ${successRate}%; - transition: width 1s ease-out; - display: flex; - align-items: center; - justify-content: center; - color: white; - font-size: 12px; - font-weight: bold; - `, - textContent: successRate + "%", + describe("unsafeWindow", () => { + it("unsafeWindow", async () => { + expect(unsafeWindow).toBeTypeOf("object"); + expect(unsafeWindow.document).toBe(document); + }); }); - // 按钮容器 - const buttonContainer = await GM.addElement(resultContainer, "div", { - style: "display: flex; gap: 10px; margin-top: 15px;", + describe("@require", () => { + it("jQuery 加载 (@require)", async () => { + expect(jQuery).toBeTypeOf("function"); + expect($).toBeTypeOf("function"); + }); }); - // 关闭按钮 - const closeBtn = await GM.addElement(buttonContainer, "button", { - textContent: "关闭", - style: ` - flex: 1; - padding: 8px 15px; - cursor: pointer; - background: #757575; - color: white; - border: none; - border-radius: 5px; - font-size: 14px; - font-weight: bold; - transition: background 0.3s; - `, - }); - closeBtn.onmouseover = () => (closeBtn.style.background = "#616161"); - closeBtn.onmouseout = () => (closeBtn.style.background = "#757575"); - closeBtn.onclick = () => resultContainer.remove(); - - // 查看日志按钮 - const logBtn = await GM.addElement(buttonContainer, "button", { - textContent: "查看详细日志", - style: ` - flex: 1; - padding: 8px 15px; - cursor: pointer; - background: #2196f3; - color: white; - border: none; - border-radius: 5px; - font-size: 14px; - font-weight: bold; - transition: background 0.3s; - `, - }); - logBtn.onmouseover = () => (logBtn.style.background = "#1976d2"); - logBtn.onmouseout = () => (logBtn.style.background = "#2196f3"); - logBtn.onclick = () => { - console.log("%c=== 完整测试报告 ===", "color: blue; font-size: 16px; font-weight: bold;"); - alert("请查看控制台中的详细测试日志"); - }; - - console.log("%c=== ScriptCat GM.* API 测试完成 ===", "color: blue; font-size: 16px; font-weight: bold;"); + await run(); })(); diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index 3be69512c..d6a424919 100644 --- a/example/tests/gm_api_sync_test.js +++ b/example/tests/gm_api_sync_test.js @@ -27,6 +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 // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbingo.org // @connect example.com @@ -36,118 +37,68 @@ (async function () { "use strict"; - console.log("%c=== ScriptCat GM API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); - - let testResults = { - passed: 0, - failed: 0, - total: 0, - }; - - // 测试辅助函数 - function test(name, fn) { - testResults.total++; - try { - fn(); - testResults.passed++; - console.log(`%c✓ ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%c✗ ${name}`, "color: red;", error); - return false; - } - } - - async function testAsync(name, fn) { - testResults.total++; - try { - await fn(); - testResults.passed++; - console.log(`%c✓ ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%c✗ ${name}`, "color: red;", error); - return false; - } - } - - // assert(expected, actual, message) - 比较两个值是否相等 - function assert(expected, actual, message) { - if (expected !== actual) { - const valueInfo = `期望 ${JSON.stringify(expected)}, 实际 ${JSON.stringify(actual)}`; - const error = message ? `${message} - ${valueInfo}` : `断言失败: ${valueInfo}`; - throw new Error(error); - } - } - - // ============ GM_info 测试 ============ - console.log("\n%c--- GM_info 测试 ---", "color: orange; font-weight: bold;"); - test("GM_info 存在", () => { - assert("object", typeof GM_info, "GM_info 应该是一个对象"); - assert(true, !!GM_info.script, "GM_info.script 应该存在"); - assert(true, !!GM_info.scriptMetaStr, "GM_info.scriptMetaStr 应该存在"); - console.log("GM_info:", GM_info); - }); - - // ============ GM_getValue/setValue 测试 ============ - console.log("\n%c--- GM 存储 API 测试 ---", "color: orange; font-weight: bold;"); + const { describe, it, expect, run } = SCTest.create({ name: "GM API 完整测试(同步)" }); - test("GM_setValue - 字符串", () => { - GM_setValue("test_string", "Hello ScriptCat"); - const value = GM_getValue("test_string"); - assert("Hello ScriptCat", value, "GM_getValue 应该返回正确的字符串值"); + describe("GM_info", () => { + it("GM_info 存在", () => { + expect(GM_info).toBeTypeOf("object"); + expect(GM_info.script).toBeTruthy(); + expect(GM_info.scriptMetaStr).toBeTruthy(); + }); }); - test("GM_setValue - 数字", () => { - GM_setValue("test_number", 42); - const value = GM_getValue("test_number"); - assert(42, value, "GM_getValue 应该返回正确的数字值"); - }); + describe("GM 存储 API", () => { + it("GM_setValue - 字符串", () => { + GM_setValue("test_string", "Hello ScriptCat"); + const value = GM_getValue("test_string"); + expect(value).toBe("Hello ScriptCat"); + }); - test("GM_setValue - 布尔值", () => { - GM_setValue("test_boolean", true); - const value = GM_getValue("test_boolean"); - assert(true, value, "GM_getValue 应该返回正确的布尔值"); - }); + it("GM_setValue - 数字", () => { + GM_setValue("test_number", 42); + const value = GM_getValue("test_number"); + expect(value).toBe(42); + }); - test("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"); - assert(JSON.stringify(obj), JSON.stringify(value), "对象应该相等"); - }); + it("GM_setValue - 布尔值", () => { + GM_setValue("test_boolean", true); + const value = GM_getValue("test_boolean"); + expect(value).toBe(true); + }); - test("GM_setValue - 数组", () => { - const arr = [1, 2, 3, "test", { key: "value" }]; - GM_setValue("test_array", arr); - const value = GM_getValue("test_array"); - assert(JSON.stringify(arr), JSON.stringify(value), "数组应该相等"); - }); + 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); + }); - test("GM_getValue - 默认值", () => { - const value = GM_getValue("non_existent_key", "default_value"); - assert("default_value", value, "不存在的键应该返回默认值"); - }); + 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); + }); - test("GM_listValues", () => { - const values = GM_listValues(); - assert(true, Array.isArray(values), "GM_listValues 应该返回数组"); - assert(true, values.includes("test_string"), "应该包含已存储的键"); - console.log("存储的键:", values); - }); + it("GM_getValue - 默认值", () => { + const value = GM_getValue("non_existent_key", "default_value"); + expect(value).toBe("default_value"); + }); - test("GM_deleteValue", () => { - GM_setValue("test_delete", "to be deleted"); - assert("to be deleted", GM_getValue("test_delete"), "值应该存在"); - GM_deleteValue("test_delete"); - assert("not_found", GM_getValue("test_delete", "not_found"), "值应该被删除"); - }); + it("GM_listValues", () => { + const values = GM_listValues(); + expect(Array.isArray(values)).toBeTruthy(); + expect(values.includes("test_string")).toBeTruthy(); + }); - // ============ GM_addValueChangeListener 测试 ============ - await (async () => { - await testAsync("GM_addValueChangeListener", () => { + 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; @@ -168,8 +119,6 @@ setTimeout(() => { // 添加监听器 listenerId = GM_addValueChangeListener("test_listener", (name, oldValue, newValue, remote) => { - console.log(`值变化监听器触发: ${name}, ${oldValue} -> ${newValue}, remote: ${remote}`); - // 清除超时 if (timeoutId) { clearTimeout(timeoutId); @@ -177,12 +126,10 @@ // 验证参数 try { - assert("test_listener", name, "监听器名称应该匹配"); - assert("initial", oldValue, "旧值应该是 'initial'"); - assert("changed", newValue, "新值应该是 'changed'"); - assert(false, remote, "remote 应该是 false(本地修改)"); - - console.log("✓ 监听器成功触发并验证参数"); + expect(name).toBe("test_listener"); + expect(oldValue).toBe("initial"); + expect(newValue).toBe("changed"); + expect(remote).toBe(false); // 清理监听器 if (typeof GM_removeValueChangeListener === "function") { @@ -216,95 +163,86 @@ }, 50); }); }); - })(); - - // ============ GM_addStyle 测试 ============ - console.log("\n%c--- GM 样式 API 测试 ---", "color: orange; font-weight: bold;"); + }); - test("GM_addStyle - CSS字符串", () => { - const css = ` + describe("GM 样式 API", () => { + it("GM_addStyle - CSS字符串", () => { + const css = ` .scriptcat-test { color: red; font-weight: bold; } `; - const element = GM_addStyle(css); - assert(true, element && element.tagName === "STYLE", "应该返回 style 元素"); - console.log("添加的样式元素:", element); + const element = GM_addStyle(css); + expect(element && element.tagName === "STYLE").toBeTruthy(); + }); }); - // ============ GM_addElement 测试 ============ - await testAsync("GM_addElement - 创建元素", async () => { - assert("function", typeof GM_addElement, "GM_addElement 应该是函数"); + 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;", - }); - assert(true, div && div.tagName === "DIV", "应该返回 div 元素"); - console.log("添加的元素:", div); + 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";', - }); - assert(true, script && script.tagName === "SCRIPT", "应该返回 script 元素"); - assert("bar", unsafeWindow.foo, "脚本内容应该执行,unsafeWindow.foo 应该是 '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: () => { - console.log("图片加载成功"); - resolve(); - }, - onerror: (error) => { - reject(new Error("图片加载失败: " + error)); - }, + // 创建脚本元素测试 + 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(); + + // 3秒后移除 + setTimeout(() => { + script.remove(); + div.remove(); + img.remove(); + }, 3000); }); - assert(true, img && img.tagName === "IMG", "应该返回 img 元素"); - console.log("添加的图片元素:", img); - - // 3秒后移除 - setTimeout(() => { - script.remove(); - div.remove(); - img.remove(); - }, 3000); }); - // ============ GM_getResourceText/URL 测试 ============ - console.log("\n%c--- GM 资源 API 测试 ---", "color: orange; font-weight: bold;"); + describe("GM 资源 API", () => { + it("GM_getResourceText", () => { + expect(GM_getResourceText).toBeTypeOf("function"); - test("GM_getResourceText", () => { - assert("function", typeof GM_getResourceText, "GM_getResourceText 应该是函数"); - - const css = GM_getResourceText("testCSS"); - assert("string", typeof css, "应该返回字符串"); - assert(163870, css.length, "资源内容长度应该是 163870"); - console.log("资源文本长度:", css.length); - }); + const css = GM_getResourceText("testCSS"); + expect(css).toBeTypeOf("string"); + expect(css.length).toBe(163870); + }); - test("GM_getResourceURL", () => { - assert("function", typeof GM_getResourceURL, "GM_getResourceURL 应该是函数"); + it("GM_getResourceURL", () => { + expect(GM_getResourceURL).toBeTypeOf("function"); - const url = GM_getResourceURL("testCSS"); - assert("string", typeof url, "应该返回字符串"); - assert(true, url.startsWith("data:") || url.startsWith("blob:"), "应该返回 data URL 或 blob URL"); - console.log("资源 URL:", url.substring(0, 50) + "..."); + const url = GM_getResourceURL("testCSS"); + expect(url).toBeTypeOf("string"); + expect(url.startsWith("data:") || url.startsWith("blob:")).toBeTruthy(); + }); }); - // ============ GM_xmlhttpRequest 测试 ============ - console.log("\n%c--- GM 网络请求 API 测试 ---", "color: orange; font-weight: bold;"); - - (async () => { - await testAsync("GM_xmlhttpRequest - GET 请求", () => { + describe("GM 网络请求 API", () => { + it("GM_xmlhttpRequest - GET 请求", () => { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", @@ -312,12 +250,11 @@ timeout: 10000, onload: (response) => { try { - assert(200, response.status, `请求状态码应该是 200`); - assert(true, !!response.responseText, "响应内容不应为空"); + expect(response.status).toBe(200); + expect(response.responseText).toBeTruthy(); const data = JSON.parse(response.responseText); - assert("object", typeof data, "应该返回有效的 JSON 对象"); - assert("https://httpbingo.org/get", data.url, "响应应该包含 url 字段"); - console.log("httpbingo 响应信息:", data.url); + expect(data).toBeTypeOf("object"); + expect(data.url).toBe("https://httpbingo.org/get"); resolve(); } catch (error) { reject(error); @@ -332,12 +269,11 @@ }); }); }); + }); - // ============ GM_notification 测试 ============ - console.log("\n%c--- GM 通知 API 测试 ---", "color: orange; font-weight: bold;"); - - test("GM_notification", () => { - assert("function", typeof GM_notification, "GM_notification 应该是函数"); + describe("GM 通知 API", () => { + it("GM_notification", () => { + expect(GM_notification).toBeTypeOf("function"); GM_notification({ text: "ScriptCat GM API 测试通知", @@ -349,47 +285,40 @@ }); console.log("通知已发送(请检查系统通知)"); }); + }); - // ============ GM_setClipboard 测试 ============ - console.log("\n%c--- GM 剪贴板 API 测试 ---", "color: orange; font-weight: bold;"); - - test("GM_setClipboard", () => { - assert("function", typeof GM_setClipboard, "GM_setClipboard 应该是函数"); + describe("GM 剪贴板 API", () => { + it("GM_setClipboard", () => { + expect(GM_setClipboard).toBeTypeOf("function"); GM_setClipboard("ScriptCat GM API 测试文本 - " + new Date().toLocaleString()); console.log("文本已复制到剪贴板(可以尝试粘贴验证)"); }); + }); - // ============ GM_openInTab 测试 ============ - console.log("\n%c--- GM 标签页 API 测试 ---", "color: orange; font-weight: bold;"); - - test("GM_openInTab (不执行)", () => { + describe("GM 标签页 API", () => { + it("GM_openInTab (不执行)", () => { // 不实际打开标签页,只测试函数是否存在 - assert("function", typeof GM_openInTab, "GM_openInTab 应该是函数"); - console.log("GM_openInTab 可用 (未实际打开标签页)"); + expect(GM_openInTab).toBeTypeOf("function"); }); + }); - // ============ GM_registerMenuCommand 测试 ============ - console.log("\n%c--- GM 菜单 API 测试 ---", "color: orange; font-weight: bold;"); - - test("GM_registerMenuCommand", () => { + describe("GM 菜单 API", () => { + it("GM_registerMenuCommand", () => { const menuId = GM_registerMenuCommand("ScriptCat 测试菜单", () => { alert("测试菜单被点击!"); }); - assert(true, menuId !== undefined, "应该返回菜单ID"); - console.log("菜单已注册,ID:", menuId); + expect(menuId !== undefined).toBeTruthy(); }); + }); - // ============ GM_cookie 测试 ============ - console.log("\n%c--- GM Cookie API 测试 ---", "color: orange; font-weight: bold;"); - - test("GM_cookie 函数存在", () => { - assert("function", typeof GM_cookie, "GM_cookie 应该是函数"); - console.log("GM_cookie API 可用"); + describe("GM Cookie API", () => { + it("GM_cookie 函数存在", () => { + expect(GM_cookie).toBeTypeOf("function"); }); // 测试 GM_cookie(action, details, callback) - await testAsync("GM_cookie - 回调风格 set", () => { + it("GM_cookie - 回调风格 set", () => { return new Promise((resolve, reject) => { GM_cookie( "set", @@ -402,7 +331,6 @@ if (error) { reject(new Error("设置 cookie 失败: " + error)); } else { - console.log("Cookie 已设置: scriptcat_test1 @ example.com"); resolve(); } } @@ -410,7 +338,7 @@ }); }); - await testAsync("GM_cookie - 回调风格 set (带 domain 和 path)", () => { + it("GM_cookie - 回调风格 set (带 domain 和 path)", () => { return new Promise((resolve, reject) => { GM_cookie( "set", @@ -425,7 +353,6 @@ if (error) { reject(new Error("设置 cookie 失败: " + error)); } else { - console.log("Cookie 已设置: scriptcat_test2 @ .example.com/path"); resolve(); } } @@ -433,7 +360,7 @@ }); }); - await testAsync("GM_cookie - 回调风格 list (by domain)", () => { + it("GM_cookie - 回调风格 list (by domain)", () => { return new Promise((resolve, reject) => { GM_cookie( "list", @@ -445,10 +372,8 @@ reject(new Error("列出 cookies 失败: " + error)); } else { try { - assert(true, Array.isArray(cookies), "应该返回数组"); - assert(true, cookies.length >= 1, "应该至少有一个 cookie"); - console.log("列出 example.com 的 cookies:", cookies.length, "个"); - console.log("示例 Cookie:", cookies[0]); + expect(Array.isArray(cookies)).toBeTruthy(); + expect(cookies.length >= 1).toBeTruthy(); resolve(); } catch (err) { reject(err); @@ -459,7 +384,7 @@ }); }); - await testAsync("GM_cookie - 回调风格 list (by url)", () => { + it("GM_cookie - 回调风格 list (by url)", () => { return new Promise((resolve, reject) => { GM_cookie( "list", @@ -471,8 +396,7 @@ reject(new Error("列出 cookies 失败: " + error)); } else { try { - assert(true, Array.isArray(cookies), "应该返回数组"); - console.log("通过 URL 列出的 cookies:", cookies.length, "个"); + expect(Array.isArray(cookies)).toBeTruthy(); resolve(); } catch (err) { reject(err); @@ -483,7 +407,7 @@ }); }); - await testAsync("GM_cookie - 回调风格 delete", () => { + it("GM_cookie - 回调风格 delete", () => { return new Promise((resolve, reject) => { GM_cookie( "delete", @@ -495,7 +419,6 @@ if (error) { reject(new Error("删除 cookie 失败: " + error)); } else { - console.log("Cookie 已删除: scriptcat_test2"); resolve(); } } @@ -503,7 +426,7 @@ }); }); - await testAsync("GM_cookie - 验证删除后", () => { + it("GM_cookie - 验证删除后", () => { return new Promise((resolve, reject) => { GM_cookie( "list", @@ -516,8 +439,7 @@ } else { try { const test2Cookie = cookies.find((c) => c.name === "scriptcat_test2"); - assert(true, !test2Cookie, "scriptcat_test2 应该已被删除"); - console.log("验证:scriptcat_test2 已被删除"); + expect(!test2Cookie).toBeTruthy(); resolve(); } catch (err) { reject(err); @@ -529,7 +451,7 @@ }); // 清理所有测试 cookies - await testAsync("清理测试 cookies", () => { + it("清理测试 cookies", () => { return new Promise((resolve, reject) => { GM_cookie("list", { domain: "example.com" }, (cookies, error) => { if (error) { @@ -540,7 +462,6 @@ const testCookies = cookies.filter((c) => c.name.startsWith("scriptcat_test")); if (testCookies.length === 0) { - console.log("没有需要清理的测试 cookies"); resolve(); return; } @@ -559,7 +480,6 @@ console.warn(`删除 cookie ${cookie.name} 失败:`, error); } if (deleteCount === testCookies.length) { - console.log(`已清理 ${testCookies.length} 个测试 cookies`); resolve(); } } @@ -568,193 +488,21 @@ }); }); }); + }); - // ============ unsafeWindow 测试 ============ - console.log("\n%c--- unsafeWindow 测试 ---", "color: orange; font-weight: bold;"); - - test("unsafeWindow", () => { - assert("object", typeof unsafeWindow, "unsafeWindow 应该存在"); - assert(document, unsafeWindow.document, "unsafeWindow.document 应该等于 document"); - console.log("unsafeWindow 可用"); - }); - - // ============ @require 测试 ============ - console.log("\n%c--- @require 测试 ---", "color: orange; font-weight: bold;"); - - test("jQuery 加载 (@require)", () => { - assert("function", typeof jQuery, "jQuery 应该已加载"); - assert("function", typeof $, "$ 应该已加载"); - console.log("jQuery 版本:", jQuery.fn.jquery); - }); - - // ============ 测试总结 ============ - console.log("\n%c=== 测试结果总结 ===", "color: blue; font-size: 16px; font-weight: bold;"); - console.log(`总测试数: ${testResults.total}`); - console.log(`%c通过: ${testResults.passed}`, "color: green; font-weight: bold;"); - console.log(`%c失败: ${testResults.failed}`, "color: red; font-weight: bold;"); - console.log(`成功率: ${((testResults.passed / testResults.total) * 100).toFixed(2)}%`); - - // 使用 GM_addElement 在页面上显示结果 - const successRate = ((testResults.passed / testResults.total) * 100).toFixed(2); - const bgColor = - testResults.failed === 0 ? "#d4edda" : testResults.failed < testResults.total / 2 ? "#fff3cd" : "#f8d7da"; - const borderColor = - testResults.failed === 0 ? "#28a745" : testResults.failed < testResults.total / 2 ? "#ffc107" : "#dc3545"; - - const resultContainer = GM_addElement(document.body, "div", { - style: ` - position: fixed; - bottom: 20px; - right: 20px; - background: ${bgColor}; - border: 3px solid ${borderColor}; - padding: 20px; - border-radius: 10px; - box-shadow: 0 4px 12px rgba(0,0,0,0.2); - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - z-index: 10000; - min-width: 350px; - animation: slideIn 0.5s ease-out; - `, - }); - - // 添加动画样式 - GM_addStyle(` - @keyframes slideIn { - from { - transform: translateX(400px); - opacity: 0; - } - to { - transform: translateX(0); - opacity: 1; - } - } - `); - - // 标题 - GM_addElement(resultContainer, "h3", { - textContent: "🐱 ScriptCat GM API 测试结果", - style: - "margin: 0 0 15px 0; color: #333; font-size: 18px; font-weight: bold; border-bottom: 2px solid " + - borderColor + - "; padding-bottom: 10px;", - }); - - // 测试统计容器 - const statsContainer = GM_addElement(resultContainer, "div", { - style: "margin-bottom: 15px;", - }); - - // 总测试数 - const totalLine = GM_addElement(statsContainer, "div", { - style: "margin: 8px 0; font-size: 14px; display: flex; justify-content: space-between;", - }); - GM_addElement(totalLine, "span", { textContent: "📊 总测试数:" }); - GM_addElement(totalLine, "strong", { - textContent: testResults.total, - style: "font-size: 16px;", - }); - - // 通过数 - const passedLine = GM_addElement(statsContainer, "div", { - style: "margin: 8px 0; font-size: 14px; display: flex; justify-content: space-between;", - }); - GM_addElement(passedLine, "span", { textContent: "✅ 通过:" }); - GM_addElement(passedLine, "strong", { - textContent: testResults.passed, - style: "color: #28a745; font-size: 16px;", - }); - - // 失败数 - const failedLine = GM_addElement(statsContainer, "div", { - style: "margin: 8px 0; font-size: 14px; display: flex; justify-content: space-between;", - }); - GM_addElement(failedLine, "span", { textContent: "❌ 失败:" }); - GM_addElement(failedLine, "strong", { - textContent: testResults.failed, - style: "color: #dc3545; font-size: 16px;", - }); - - // 成功率 - const rateLine = GM_addElement(statsContainer, "div", { - style: "margin: 8px 0; font-size: 14px; display: flex; justify-content: space-between;", - }); - GM_addElement(rateLine, "span", { textContent: "📈 成功率:" }); - GM_addElement(rateLine, "strong", { - textContent: successRate + "%", - style: - "color: " + (successRate >= 90 ? "#28a745" : successRate >= 70 ? "#ffc107" : "#dc3545") + "; font-size: 16px;", - }); - - // 进度条 - const progressBar = GM_addElement(resultContainer, "div", { - style: "background: #e9ecef; height: 20px; border-radius: 10px; overflow: hidden; margin: 15px 0;", - }); - GM_addElement(progressBar, "div", { - style: ` - background: linear-gradient(90deg, #28a745, #20c997); - height: 100%; - width: ${successRate}%; - transition: width 1s ease-out; - display: flex; - align-items: center; - justify-content: center; - color: white; - font-size: 12px; - font-weight: bold; - `, - textContent: successRate + "%", + describe("unsafeWindow", () => { + it("unsafeWindow", () => { + expect(unsafeWindow).toBeTypeOf("object"); + expect(unsafeWindow.document).toBe(document); }); + }); - // 按钮容器 - const buttonContainer = GM_addElement(resultContainer, "div", { - style: "display: flex; gap: 10px; margin-top: 15px;", + describe("@require", () => { + it("jQuery 加载 (@require)", () => { + expect(jQuery).toBeTypeOf("function"); + expect($).toBeTypeOf("function"); }); + }); - // 关闭按钮 - const closeBtn = GM_addElement(buttonContainer, "button", { - textContent: "关闭", - style: ` - flex: 1; - padding: 8px 15px; - cursor: pointer; - background: #6c757d; - color: white; - border: none; - border-radius: 5px; - font-size: 14px; - font-weight: bold; - transition: background 0.3s; - `, - }); - closeBtn.onmouseover = () => (closeBtn.style.background = "#5a6268"); - closeBtn.onmouseout = () => (closeBtn.style.background = "#6c757d"); - closeBtn.onclick = () => resultContainer.remove(); - - // 查看日志按钮 - const logBtn = GM_addElement(buttonContainer, "button", { - textContent: "查看详细日志", - style: ` - flex: 1; - padding: 8px 15px; - cursor: pointer; - background: #007bff; - color: white; - border: none; - border-radius: 5px; - font-size: 14px; - font-weight: bold; - transition: background 0.3s; - `, - }); - logBtn.onmouseover = () => (logBtn.style.background = "#0056b3"); - logBtn.onmouseout = () => (logBtn.style.background = "#007bff"); - logBtn.onclick = () => { - console.log("%c=== 完整测试报告 ===", "color: blue; font-size: 16px; font-weight: bold;"); - alert("请查看控制台中的详细测试日志"); - }; - - console.log("%c=== ScriptCat GM API 测试完成 ===", "color: blue; font-size: 16px; font-weight: bold;"); - })(); + await run(); })(); diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index a3df884e0..6419be3b1 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -8,9 +8,8 @@ // @grant GM_download // @grant GM.download // @grant GM_xmlhttpRequest -// @grant GM_setValue -// @grant GM_getValue // @grant GM_info +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js // @connect httpbingo.org // @connect raw.githubusercontent.com // @connect cdn.jsdelivr.net @@ -22,15 +21,15 @@ /* WHAT THIS DOES -------------- - - Builds an in-page test runner panel for GM_download / GM.download. - - Drives a battery of tests covering options, callbacks, modes, url forms, and edge paths. + - Drives a battery of tests covering options, callbacks, modes, url forms, and edge paths + for GM_download / GM.download, reported through the shared example/tests/lib/sctest.js panel. - Every download actually writes a file to disk; all files go under a - user-configurable sub-folder (default: "scriptcat-gmdl-tests/") so cleanup - is one rm -rf away. + user-configurable sub-folder (edit the "prefix" field on the panel's + "GM_download 自动套件" params row) so cleanup is one rm -rf away. WHAT IT COVERS -------------- - Auto: + Auto (suite "GM_download 自动套件" — click its "运行" button in the panel): ✓ GM_download(url, name) — string form ✓ GM_download({ ... }) — options-object form ✓ GM.download({ ... }) — promise form (resolve / reject) @@ -43,8 +42,10 @@ ✓ headers (passed through to xhr in native mode) ✓ edge: bad url, empty url, 404, blocked host (@connect missing) - Manual (verdict-driven — human clicks Mark Pass/Fail/Skip; auto-skips after a timeout - so a forgotten test can never hang the runner): + Manual (suite "GM_download 手动用例" — its own "运行" button, deliberately excluded from the + auto suite): each case prints instructions via console.log and shows a small floating verdict + bar with Mark Pass / Mark Fail / Skip; auto-skips after a timeout so a forgotten test can't + hang the suite. • saveAs: true — dialog appears, user saves • saveAs: true — user cancels → must NOT trigger onerror (the bug evaluated above) • native + handle.abort() while downloading → no onload, no onerror after abort @@ -64,17 +65,20 @@ 1. Install in ScriptCat / Tampermonkey. Grant all listed permissions. 2. Open any page whose URL matches *?GM_DOWNLOAD_TEST_SC (e.g. https://example.com/?GM_DOWNLOAD_TEST_SC) - 3. The panel appears bottom-right. Click "Run Auto" to start. - 4. Files land in your downloads folder under the prefix shown in the panel. - Click "Set prefix" to change it. Click "Clear log" to reset counts. + 3. The sctest panel appears bottom-right. Click "运行全部" for the automated battery; the + human-in-the-loop cases remain available for individual confirmation in the panel. + 4. Files land in your downloads folder under the prefix shown on the panel's params row — + edit that field before clicking "运行" to change it. */ const enableTool = true; -(function () { +(async function () { "use strict"; if (!enableTool) return; - // ---------- Tiny DOM helper ---------- + const { describe, it, itManual, expect, run } = SCTest.create({ name: "GM_download 测试" }); + + // ---------- Tiny DOM helper (only used for the manual-verdict bar below) ---------- function h(tag, props = {}, ...children) { const el = document.createElement(tag); Object.entries(props).forEach(([k, v]) => { @@ -86,33 +90,28 @@ const enableTool = true; return el; } - function escapeHtml(s) { - return String(s).replace( - /[&<>"']/g, - (m) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[m] - ); - } - - function fmtMs(ms) { - return ms < 1000 ? `${ms | 0}ms` : `${(ms / 1000).toFixed(2)}s`; + function btnStyle(bg) { + return { + background: bg || "#2a6df1", + color: "white", + border: "0", + padding: "6px 10px", + borderRadius: "6px", + cursor: "pointer", + font: "inherit", + }; } - // ---------- Settings (persisted) ---------- - // Prefix is the sub-folder under the user's Downloads dir. Trailing slash auto-appended. + // ---------- Settings ---------- + // Prefix is the sub-folder under the user's Downloads dir, live-edited via the "GM_download + // 自动套件" suite's params row (see describe() below) instead of the old GM_getValue/GM_setValue + // persistence — the panel input already round-trips the value for the lifetime of the page. + const autoParams = { prefix: "sc-test-" }; function getPrefix() { - let p = ""; - try { - p = (typeof GM_getValue === "function" ? GM_getValue("dl_prefix", "") : "") || ""; - } catch { /* ignore */ } - if (!p) p = "scriptcat-gmdl-tests/"; + let p = autoParams.prefix || "sc-test-"; if (!p.endsWith("/")) p += "/"; return p; } - function setPrefix(p) { - try { - if (typeof GM_setValue === "function") GM_setValue("dl_prefix", p); - } 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). @@ -139,202 +138,6 @@ const enableTool = true; // httpbingo deterministic endpoint — returns N random bytes. const HB = "https://httpbingo.org"; - // ---------- Panel ---------- - const panel = h( - "div", - { - id: "gmdl-test-panel", - style: { - position: "fixed", bottom: "12px", right: "12px", - width: "520px", maxHeight: "78vh", overflow: "auto", - zIndex: 2147483647, - 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)", - border: "1px solid #333", - }, - }, - h( - "div", - { - style: { - 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" } }, - `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", { id: "counts", style: { marginLeft: "auto" } }, "…") - ) - ), - h("button", { id: "start", style: btnStyle() }, "Run Auto"), - 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"), - - // 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("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}`) - ), - - // Manual section. - 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" } }, - "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", { style: { fontWeight: "600", color: "#fbbf24" } }, "⏳ Awaiting your action"), - h("div", { id: "awaitingLabel", style: { fontSize: "12px", opacity: .85, marginTop: "2px" } }, "") - ), - 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"), - h("button", { id: "awaitingFail", style: btnStyle("#dc2626") }, "✗ Mark Fail"), - h("button", { id: "awaitingSkip", style: btnStyle("#475569") }, "Skip") - ) - ), - - // Queue. - 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, - }, - }, "(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: "log", style: { padding: "10px 12px" } }) - ); - document.documentElement.appendChild(panel); - - function btnStyle(bg) { - return { - background: bg || "#2a6df1", - color: "white", - border: "0", - padding: "6px 10px", - borderRadius: "6px", - cursor: "pointer", - font: "inherit", - }; - } - - const $log = panel.querySelector("#log"); - const $counts = panel.querySelector("#counts"); - const $status = panel.querySelector("#status"); - const $queue = panel.querySelector("#queue"); - const $prefix = panel.querySelector("#prefix"); - const $progressWrap = panel.querySelector("#progressWrap"); - const $progressLabel = panel.querySelector("#progressLabel"); - const $progressBar = panel.querySelector("#progressBar"); - const $manualButtons = panel.querySelector("#manualButtons"); - const $awaitingWrap = panel.querySelector("#awaitingWrap"); - const $awaitingLabel = panel.querySelector("#awaitingLabel"); - const $awaitingTimer = panel.querySelector("#awaitingTimer"); - const $awaitingPass = panel.querySelector("#awaitingPass"); - const $awaitingFail = panel.querySelector("#awaitingFail"); - const $awaitingSkip = panel.querySelector("#awaitingSkip"); - const $awaitingAction = panel.querySelector("#awaitingAction"); - - panel.querySelector("#clear").addEventListener("click", () => { - $log.textContent = ""; - state.pass = state.fail = state.skip = 0; - setCounts(); - setStatus("idle"); - setQueue([]); - hideProgress(); - }); - panel.querySelector("#start").addEventListener("click", () => runAuto()); - panel.querySelector("#setPrefix").addEventListener("click", () => { - const cur = getPrefix(); - const next = prompt("Download sub-folder (under Downloads). Trailing slash optional.", cur); - if (next == null) return; - setPrefix(next.trim() || "scriptcat-gmdl-tests/"); - $prefix.textContent = getPrefix(); - logLine(`Prefix set to ${escapeHtml(getPrefix())}`); - }); - - function logLine(html, cls = "") { - const line = h("div", { style: { padding: "6px 0", borderBottom: "1px dashed #2a2a2a" } }); - line.innerHTML = html; - if (cls) line.className = cls; - $log.prepend(line); - } - - // ---------- Counters & status ---------- - const state = { pass: 0, fail: 0, skip: 0 }; - function setCounts() { - $counts.textContent = `✅ ${state.pass} ❌ ${state.fail} ⏭️ ${state.skip}`; - } - setCounts(); - 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 fail(msg, extra) { - state.fail++; setCounts(); - logLine( - `❌ ${escapeHtml(msg)}${extra ? `
${escapeHtml(extra)}
` : ""}`, - "fail" - ); - } - function skip(msg) { state.skip++; setCounts(); logLine(`⏭️ ${escapeHtml(msg)}`); } - - function showProgress(label) { - $progressWrap.style.display = ""; - $progressLabel.textContent = label; - $progressBar.style.width = "0%"; - } - function updateProgress(loaded, total) { - if (total > 0) { - $progressBar.style.width = Math.min(100, Math.round((loaded / total) * 100)) + "%"; - } else { - // Unknown total — fake an indeterminate bar that creeps up. - const cur = parseFloat($progressBar.style.width) || 0; - $progressBar.style.width = Math.min(95, cur + 5) + "%"; - } - } - function hideProgress() { - $progressWrap.style.display = "none"; - $progressBar.style.width = "0%"; - } - - // ---------- 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}`); - } - 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; @@ -348,19 +151,56 @@ const enableTool = true; }); } - // ---------- Awaiting bar (manual-test verdict UI) ---------- + // ---------- Manual-verdict bar ---------- // The manual tests can't be "asserted" purely from JS — the contract often is // "user sees a dialog, picks Cancel, the script doesn't crash". So we hand the - // verdict back to the human via Pass/Fail/Skip buttons. To avoid the runner - // hanging forever if the human disappears, every manual test runs under a - // countdown that auto-skips when it hits zero. + // verdict back to the human via Pass/Fail/Skip buttons on this small floating bar, + // kept separate from the sctest reporting panel: that panel only reports a case's + // outcome once its fn() has returned, so it can't pause mid-case for a human decision, + // and the abort-download test genuinely needs a live button while a download streams. + // To avoid the runner hanging forever if the human disappears, every manual test runs + // under a countdown that auto-skips when it hits zero. + const verdictBar = h( + "div", + { + id: "gmdl-verdict-bar", + style: { + // 靠左:sctest 面板固定在右下角(right:16px 起、最高 80vh),原来的 right:12px 会让 + // 面板压住本条的按钮行——两者 z-index 同为最大值,面板挂载更晚所以吃掉点击。 + position: "fixed", top: "12px", left: "12px", + width: "360px", zIndex: 2147483647, display: "none", + background: "#1a1408", 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)", + border: "1px solid #5b4a1a", padding: "10px 12px", + }, + }, + h("div", { style: { fontWeight: "600", color: "#fbbf24" } }, "⏳ Awaiting your action"), + h("div", { id: "verdictLabel", style: { fontSize: "12px", opacity: .85, margin: "4px 0 2px" } }, ""), + h("div", { id: "verdictTimer", style: { fontSize: "12px", opacity: .85, fontFamily: "ui-monospace, monospace", marginBottom: "6px" } }, ""), + h("div", { style: { display: "flex", gap: "6px", flexWrap: "wrap" } }, + h("button", { id: "verdictAction", style: { ...btnStyle("#0ea5e9"), display: "none" } }, ""), + h("button", { id: "verdictPass", style: btnStyle("#16a34a") }, "✓ Mark Pass"), + h("button", { id: "verdictFail", style: btnStyle("#dc2626") }, "✗ Mark Fail"), + h("button", { id: "verdictSkip", style: btnStyle("#475569") }, "Skip") + ) + ); + document.documentElement.appendChild(verdictBar); + + const $verdictLabel = verdictBar.querySelector("#verdictLabel"); + const $verdictTimer = verdictBar.querySelector("#verdictTimer"); + const $verdictPass = verdictBar.querySelector("#verdictPass"); + const $verdictFail = verdictBar.querySelector("#verdictFail"); + const $verdictSkip = verdictBar.querySelector("#verdictSkip"); + const $verdictAction = verdictBar.querySelector("#verdictAction"); + let _verdictResolve = null; let _verdictTimerId = null; let _verdictDeadline = 0; function showAwaiting(label, deadlineSecs) { - $awaitingLabel.innerHTML = label; // caller controls HTML, we trust it - $awaitingWrap.style.display = ""; + $verdictLabel.textContent = label; + verdictBar.style.display = ""; _verdictDeadline = performance.now() + deadlineSecs * 1000; tickAwaitingTimer(); if (_verdictTimerId) clearInterval(_verdictTimerId); @@ -368,21 +208,21 @@ const enableTool = true; } function tickAwaitingTimer() { const remaining = Math.max(0, Math.ceil((_verdictDeadline - performance.now()) / 1000)); - $awaitingTimer.textContent = `auto-skip in ${remaining}s`; + $verdictTimer.textContent = `auto-skip in ${remaining}s`; if (remaining === 0) { // Time's up — auto-skip so the runner doesn't hang. resolveVerdict({ verdict: "skip", reason: "timed out waiting for verdict" }); } } function hideAwaiting() { - $awaitingWrap.style.display = "none"; - $awaitingLabel.innerHTML = ""; - $awaitingTimer.textContent = ""; + verdictBar.style.display = "none"; + $verdictLabel.textContent = ""; + $verdictTimer.textContent = ""; 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 = ""; - $awaitingAction.onclick = null; + $verdictAction.style.display = "none"; + $verdictAction.textContent = ""; + $verdictAction.onclick = null; } function resolveVerdict(v) { if (!_verdictResolve) return; @@ -391,43 +231,42 @@ const enableTool = true; hideAwaiting(); r(v); } - $awaitingPass.addEventListener("click", () => resolveVerdict({ verdict: "pass" })); - $awaitingFail.addEventListener("click", () => { + $verdictPass.addEventListener("click", () => resolveVerdict({ verdict: "pass" })); + $verdictFail.addEventListener("click", () => { const reason = prompt("Why did this fail? (optional)", "") || "marked failed by user"; resolveVerdict({ verdict: "fail", reason }); }); - $awaitingSkip.addEventListener("click", () => resolveVerdict({ verdict: "skip", reason: "skipped by user" })); + $verdictSkip.addEventListener("click", () => resolveVerdict({ verdict: "skip", reason: "skipped by user" })); /** - * Wait for the human to give a verdict via the awaiting bar. - * @param {string} promptHtml HTML shown in the awaiting bar (be careful — trusted source). + * Wait for the human to give a verdict via the verdict bar. + * @param {string} promptText Plain-text instructions shown on the bar. * @param {number} [deadlineSecs=120] Auto-skip after this many seconds of no input. * @returns {Promise<{verdict: "pass"|"fail"|"skip", reason?: string}>} */ - function awaitVerdict(promptHtml, deadlineSecs = 120) { + function awaitVerdict(promptText, deadlineSecs = 120) { return new Promise((resolve) => { _verdictResolve = resolve; - showAwaiting(promptHtml, deadlineSecs); + showAwaiting(promptText, deadlineSecs); }); } /** - * Register an in-flight action button on the awaiting bar. + * Register an in-flight action button on the verdict bar. * Use to expose things like "🛑 Abort download" while we wait for a verdict. * The button auto-hides when the verdict resolves (or the next showAwaiting() is called). * @param {string} label Button text. * @param {() => void} onClick Click handler. Stays attached until the bar hides. */ function showAwaitingAction(label, onClick) { - $awaitingAction.textContent = label; - $awaitingAction.style.display = ""; - $awaitingAction.onclick = (ev) => { + $verdictAction.textContent = label; + $verdictAction.style.display = ""; + $verdictAction.onclick = (ev) => { ev.preventDefault(); try { onClick(); } catch (e) { console.error("awaiting action handler threw:", e); } }; } - // ---------- GM_download wrappers ---------- /** @@ -450,7 +289,6 @@ const enableTool = true; onprogress(p) { progress.push(p); 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 {} @@ -481,15 +319,14 @@ const enableTool = true; onprogress(p) { progress.push(p); try { details.onprogress && details.onprogress(p); } catch {} - updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1); }, }; const ret = GM.download(opts); return { promise: ret, abort: ret && ret.abort, progress }; } - // ---------- The auto test suite ---------- - // Each entry: { name, manual?: boolean, run: async () => void } + // ---------- The auto/manual test data ---------- + // Each entry: { name, manual: boolean, run: async () => void } const tests = []; function autoTest(name, run) { tests.push({ name, manual: false, run }); } @@ -497,8 +334,8 @@ const enableTool = true; // 1) sanity: APIs exist autoTest("APIs exist (GM_download / GM.download)", async () => { - assertEq(typeof GM_download, "function", "GM_download must be a function"); - assertTrue(typeof GM !== "undefined" && typeof GM.download === "function", "GM.download must exist"); + expect(typeof GM_download).toBe("function"); + expect(typeof GM !== "undefined" && typeof GM.download === "function").toBeTruthy(); }); // 2) string-form: GM_download(url, name) — passes through to the options form @@ -514,11 +351,11 @@ const enableTool = true; try { h = GM_download(blobUrl, name); } catch (e) { return reject(e); } - assertTrue(h && typeof h.abort === "function", "handle.abort must be a function"); + expect(h && typeof h.abort === "function").toBeTruthy(); // Wait briefly to give the SW a chance to dispatch the download. setTimeout(() => resolve({ handle: h }), 800); }), 5000, "string-form"); - assertTrue(!!result, "completed"); + expect(!!result).toBeTruthy(); } finally { URL.revokeObjectURL(blobUrl); } @@ -535,7 +372,7 @@ const enableTool = true; conflictAction: "uniquify", }); const r = await withTimeout(promise, 10000, "blob: URL download"); - assertEq(r.kind, "load", "onload should fire"); + expect(r.kind).toBe("load"); } finally { URL.revokeObjectURL(blobUrl); } @@ -549,7 +386,7 @@ const enableTool = true; name, }); const r = await withTimeout(promise, 10000, "Blob object download"); - assertEq(r.kind, "load", "onload should fire"); + expect(r.kind).toBe("load"); }); // 5) File object as url — File extends Blob, should also work @@ -560,7 +397,7 @@ const enableTool = true; name, }); const r = await withTimeout(promise, 10000, "File object download"); - assertEq(r.kind, "load", "onload should fire"); + expect(r.kind).toBe("load"); }); // 6) data: URL @@ -572,7 +409,7 @@ const enableTool = true; name, }); const r = await withTimeout(promise, 10000, "data: URL download"); - assertEq(r.kind, "load", "onload should fire"); + expect(r.kind).toBe("load"); }); // 7) GM.download promise form @@ -583,7 +420,7 @@ const enableTool = true; name, }); const r = await withTimeout(promise, 10000, "GM.download promise"); - assertTrue(r && typeof r === "object", "should resolve to an object"); + expect(r && typeof r === "object").toBeTruthy(); }); // 8) native mode — uses SW fetch xhr, multiple onprogress events expected @@ -603,9 +440,9 @@ const enableTool = true; // 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"); + expect(!!r).toBeTruthy(); // Sanity bound: 4KB shouldn't take 20s on any sane net. - assertTrue(performance.now() - t0 < 20000, "completed in time"); + expect(performance.now() - t0 < 20000).toBeTruthy(); }); // 9) browser mode — chrome.downloads only @@ -621,7 +458,7 @@ const enableTool = true; ontimeout: reject, }); }), 20000, "browser mode"); - assertTrue(!!r, "onload received"); + expect(!!r).toBeTruthy(); }); // 10) onprogress structure (native mode) @@ -638,11 +475,11 @@ const enableTool = true; onerror: reject, }); }), 20000, "progress shape"); - assertTrue(progresses.length > 0, "got at least one progress event"); + expect(progresses.length > 0).toBeTruthy(); const last = progresses[progresses.length - 1]; - assertTrue("loaded" in last, "progress has loaded"); - assertTrue("total" in last, "progress has total"); - assertTrue(last.mode === "native" || last.mode === "browser", `mode field present, got ${last.mode}`); + expect("loaded" in last).toBeTruthy(); + expect("total" in last).toBeTruthy(); + expect(last.mode === "native" || last.mode === "browser").toBeTruthy(); }); // 11) conflictAction "overwrite" — second download to the same name should replace @@ -659,7 +496,7 @@ const enableTool = true; onerror: reject, }); }), 10000, "overwrite #1"); - assertTrue(!!a, "first write succeeded"); + expect(!!a).toBeTruthy(); const b = await withTimeout(new Promise((resolve, reject) => { GM_download({ url: URL.createObjectURL(new Blob(["v2"])), @@ -669,8 +506,8 @@ const enableTool = true; onerror: reject, }); }), 10000, "overwrite #2"); - assertTrue(!!b, "second write succeeded (overwrite)"); - skip(`(visual check) ${fixedName} should now contain "v2"`); + expect(!!b).toBeTruthy(); + // (visual check) fixedName should now contain "v2" — left for the human running the panel. }); // 12) conflictAction "uniquify" — second download gets " (1)" suffix @@ -694,7 +531,7 @@ const enableTool = true; onerror: reject, }); }), 10000, "uniquify #2"); - skip(`(visual check) you should see both uniquify-target.txt and uniquify-target (1).txt`); + // (visual check) both fixedName and its " (1)" sibling should exist — no automatable assertion. }); // 13) headers honored in native mode — httpbingo /headers echoes request headers @@ -715,30 +552,26 @@ const enableTool = true; onerror: reject, }); }), 20000, "headers passthrough"); - assertTrue(!!r, "onload received"); - skip(`(visual check) open ${name} — X-Custom-Probe should be echoed in the body`); + expect(!!r).toBeTruthy(); + // (visual check) open the file — X-Custom-Probe should be echoed in the body. }); // 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; const h = GM_download({ url: `${HB}/bytes/65536`, name, downloadMode: "native", onload() { onloadCalled = true; }, - onerror() { onerrorCalled = true; }, }); h.abort(); // Give the system 1.5s to (not) call any callbacks. await new Promise((r) => setTimeout(r, 1500)); - assertEq(onloadCalled, false, "onload should not fire after immediate abort"); - // Note: onerror may still fire in some impls — we accept either no-call or a - // generic error. The important contract is: no successful onload. - if (onerrorCalled) { - skip("onerror fired post-abort (implementation choice, not a failure)"); - } + // Note: onerror may still fire in some impls after an immediate abort — we don't assert + // on it either way. The important contract is: no successful onload. + expect(onloadCalled).toBe(false); }); // 15) abort() after onload — should be a no-op, no exceptions @@ -776,8 +609,8 @@ const enableTool = true; // Safety timeout setTimeout(resolve, 8000); }); - assertEq(onloadCalled, false, "onload must NOT fire"); - assertTrue(!!errSeen, "onerror should fire"); + expect(onloadCalled).toBe(false); + expect(!!errSeen).toBeTruthy(); }); // 17) bad URL string — onerror or thrown @@ -795,27 +628,30 @@ const enableTool = true; setTimeout(resolve, 4000); }); } 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"); + expect(onloadCalled).toBe(false); + expect(errSeen != null || threw != null).toBeTruthy(); }); - // 18) empty URL — should be rejected - autoTest("empty URL — onerror or thrown", async () => { + // 18) empty URL — new URL("", location.href) resolves to the current page per RFC 3986 + // (src/app/service/content/gm_api/gm_xhr.ts:230, shared by GM_download at :265), so an empty + // url downloads the current document rather than erroring. + autoTest("empty URL — resolves to the current page, succeeds (not an error)", async () => { const name = nameFor("empty-url", "bin"); let onloadCalled = false, errSeen = null, threw = null; try { - await new Promise((resolve) => { + await new Promise((resolve, reject) => { GM_download({ url: "", name, onload() { onloadCalled = true; resolve(); }, - onerror(e) { errSeen = e || true; resolve(); }, + onerror(e) { errSeen = e || true; reject(new Error(`unexpected onerror: ${JSON.stringify(e)}`)); }, }); - setTimeout(resolve, 3000); + setTimeout(() => reject(new Error("timed out waiting for onload")), 3000); }); } 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"); + expect(threw).toBe(null); + expect(onloadCalled).toBe(true); + expect(errSeen).toBe(null); }); // 19) name with subdirectories — folder is created under Downloads/ @@ -829,8 +665,8 @@ const enableTool = true; onerror: reject, }); }), 10000, "nested name"); - assertTrue(!!r, "onload received"); - skip(`(visual check) ${name} should exist with nested folders`); + expect(!!r).toBeTruthy(); + // (visual check) name should exist with nested folders — no automatable assertion. }); // 20) windows-illegal chars in name — should be sanitized (replaced with '-') @@ -846,7 +682,7 @@ const enableTool = true; onerror: reject, }); }), 10000, "illegal chars"); - assertTrue(!!r, "onload received — name was sanitized"); + expect(!!r).toBeTruthy(); }); // 21) GM.download rejection — invalid URL should reject the promise @@ -863,17 +699,17 @@ const enableTool = true; // withTimeout firing is acceptable too — counts as "did not resolve" rejected = e; } - assertTrue(resolved == null, "should not resolve"); - assertTrue(rejected != null, "should reject (or at least not resolve)"); + expect(resolved == null).toBeTruthy(); + expect(rejected != null).toBeTruthy(); }); // ---------- Manual tests (verdict-driven) ---------- // // Each manual test: - // 1. Logs a clear "what to do" and "what to expect" line. + // 1. Logs a clear "what to do" and "what to expect" line to the console. // 2. Kicks off the download. - // 3. Calls awaitVerdict(...) — the human reads the log, performs the action, - // then clicks Mark Pass / Mark Fail / Skip in the awaiting bar. + // 3. Calls awaitVerdict(...) — the human reads the instructions, performs the action, + // then clicks Mark Pass / Mark Fail / Skip on the verdict bar. // 4. Records any callback events as they come in so the verdict isn't blind. // 5. Has an auto-skip timeout so a forgotten test can't wedge the runner. // @@ -882,33 +718,32 @@ const enableTool = true; manualTest("saveAs: true — save dialog appears and saves", async () => { const name = nameFor("manual-saveAs", "txt"); - logLine(`▶ Manual #1: ${escapeHtml(name)}`); - logLine(`→ Expected: a Save As dialog appears. Pick a location and confirm.`); - logLine(`→ After the file lands, click Mark Pass. If no dialog shows, click Mark Fail.`); + console.log(`▶ Manual #1: ${name}`); + console.log(`→ Expected: a Save As dialog appears. Pick a location and confirm.`); + console.log(`→ After the file lands, click "Mark Pass". If no dialog shows, click "Mark Fail".`); const events = []; const blobUrl = URL.createObjectURL(TEXT_BLOB); GM_download({ 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]); console.log(`→ event: onload ${JSON.stringify(d)}`); }, + onerror: (e) => { events.push(["onerror", e]); console.log(`→ 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 === "skip") SCTest.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) console.log(`note: no callbacks fired — Pass accepted but worth checking`); }); manualTest("Cancel saveAs dialog — must NOT be onerror", async () => { const name = nameFor("manual-saveAs-cancel", "txt"); - logLine(`▶ Manual #2: ${escapeHtml(name)}`); - 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.`); + console.log(`▶ Manual #2: ${name}`); + console.log(`→ Expected: a Save As dialog appears. Click Cancel.`); + console.log(`→ The contract: onload may fire (compat layer maps save_cancelled → onload), but onerror MUST NOT fire.`); let sawOnerror = false, sawOnload = false; const events = []; const blobUrl = URL.createObjectURL(TEXT_BLOB); @@ -916,19 +751,19 @@ const enableTool = true; 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"); console.log(`→ event: onload ${JSON.stringify(d)}`); }, + onerror: (e) => { sawOnerror = true; events.push("onerror"); console.log(`→ 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.", + "When the Save As dialog appears, click Cancel. Watch the console: 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 === "skip") SCTest.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 (!sawOnload && !sawOnerror) console.log(`note: neither onload nor onerror fired — implementation may swallow the cancel silently`); }); // Note on cancel testing: @@ -947,43 +782,39 @@ const enableTool = true; const name = nameFor("manual-abort-native", "bin"); // 100 MB over plain HTTP, with a cache-buster. const url = `http://ipv4.download.thinkbroadband.com/100MB.zip?t=${Date.now()}`; - logLine(`▶ Manual #3a: ${escapeHtml(name)}`); - 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.`); + console.log(`▶ Manual #3a: ${name}`); + console.log(`→ A 100 MB download (native mode) starts. Give it a second or two to get going`); + console.log(` (check chrome://downloads if you want to see live progress).`); + console.log(`→ Click the "🛑 Abort download" button on the verdict bar. Then Mark Pass.`); + console.log(`→ Contract: after abort(), no onload and no onerror should fire.`); let sawOnload = false, sawOnerror = false, lastProgress = null, abortCalledAt = 0; const handle = GM_download({ url, name, downloadMode: "native", - onprogress: (p) => { - 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)}`); }, + onprogress: (p) => { lastProgress = p; }, + onload: (d) => { sawOnload = true; console.log(`→ 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"; - logLine(`→ event: onerror (${sinceAbort}): ${JSON.stringify(e)}`); + console.log(`→ event: onerror (${sinceAbort}): ${JSON.stringify(e)}`); }, }); - showProgress("downloading 100 MB (abort me)"); showAwaitingAction("🛑 Abort download", () => { - if (abortCalledAt) { logLine("→ abort already requested"); return; } + if (abortCalledAt) { console.log("→ abort already requested"); return; } abortCalledAt = performance.now(); - logLine(`→ calling handle.abort()`); - try { handle.abort(); } catch (e) { logLine(`abort threw: ${escapeHtml(String(e))}`); } + console.log(`→ calling handle.abort()`); + try { handle.abort(); } catch (e) { console.log(`abort threw: ${String(e)}`); } }); const v = await awaitVerdict( - "Wait for progress events, click 🛑 Abort download, then Mark Pass. (Mark Fail if onload fires after abort.)", + "Wait a couple seconds, click 🛑 Abort download, then Mark Pass. (Mark Fail if onload fires after abort.)", 300 ); - hideProgress(); 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 === "skip") SCTest.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) console.log(`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})`); }); @@ -995,132 +826,65 @@ const enableTool = true; // 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.`); + console.log(`▶ Manual #3b: ${name}`); + console.log(`→ A 100 MB download (browser mode) starts — chrome://downloads will show it with a real progress bar.`); + console.log(`→ Open chrome://downloads, find the entry, click Cancel.`); + console.log(`→ 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)}`); }, + onload: (d) => { sawOnload = true; onloadData = d; console.log(`→ event: onload ${JSON.stringify(d)}`); }, + onerror: (e) => { sawOnerror = true; console.log(`→ 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 === "skip") SCTest.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.`); + if (!sawOnload) console.log(`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"); const content = `hello at ${new Date().toISOString()} - tag ${RUN_TAG}`; const blobUrl = URL.createObjectURL(new Blob([content], { type: "text/plain" })); - logLine(`▶ Manual #4: a tiny file is being written.`); - logLine(`→ Expected: open ${escapeHtml(name)} in your Downloads folder.`); - logLine(`→ It must contain this exact text: ${escapeHtml(content)}`); + console.log(`▶ Manual #4: a tiny file is being written.`); + console.log(`→ Expected: open ${name} in your Downloads folder.`); + console.log(`→ It must contain this exact text: ${content}`); let landed = false; 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; console.log("→ event: onload — file should be on disk now."); }, + onerror: (e) => { console.log(`→ event: onerror ${JSON.stringify(e)}`); }, }); - const v = await awaitVerdict(`Open ${escapeHtml(name)} and confirm the contents match.`, 240); + const v = await awaitVerdict(`Open ${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 === "skip") SCTest.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) console.log(`note: marked Pass but onload didn't fire — file presence is the source of truth here`); }); - // ---------- Runner ---------- - async function runOne(t, idx, total) { - setStatus(`running (${idx + 1}/${total}): ${t.name}`); - if (!t.manual) showProgress(t.name); - const title = `• ${t.name}`; - const t0 = performance.now(); - try { - logLine(`▶️ ${escapeHtml(t.name)}`); - await t.run(); - pass(`${title} (${fmtMs(performance.now() - t0)})`); - } catch (e) { - const extra = e && e.stack ? e.stack : String(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()}`); - } else { - fail(`${title} (${fmtMs(performance.now() - t0)})`, extra); - } - } finally { - hideProgress(); - // Any leftover awaiting bar from a thrown-mid-flight test gets cleaned up. - hideAwaiting(); + // ---------- Suites ---------- + // manual: true 用例原本被 runAuto()(旧版 :1083)用 tests.filter((t) => !t.manual) 排除在批量 + // 之外;迁移拆成两个 auto:false 的 suite,“运行全部”只执行自动用例,人工用例仍逐条确认。 + describe("GM_download 自动套件", { auto: false, params: autoParams }, () => { + for (const t of tests.filter((t) => !t.manual)) { + it(t.name, () => t.run()); } - } - - 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"; }); - } + }); - async function runAuto() { - 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); - 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)}-`); - for (let i = 0; i < auto.length; i++) { - await runOne(auto[i], i, auto.length); - setQueue(names.slice(i + 1)); - } - setStatus("done"); - logLine(`Done. Summary — ✅ ${state.pass} ❌ ${state.fail} ⏭️ ${state.skip}`); - } finally { - running = false; - setAllButtonsDisabled(false); + describe("GM_download 手动用例", { auto: false }, () => { + for (const t of tests.filter((t) => t.manual)) { + itManual(t.name, { hint: "按用例说明完成人工验证" }); } - } - - // 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); }); - // ---------- 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(`Tip: change the prefix above if you want files in a different sub-folder.`); - setStatus("idle"); - - // No auto-start: GM_download writes to disk, so we wait for explicit user action. + await run(); })(); diff --git a/example/tests/gm_menu_test.js b/example/tests/gm_menu_test.js index 216015dfb..f60b1f17d 100644 --- a/example/tests/gm_menu_test.js +++ b/example/tests/gm_menu_test.js @@ -6,6 +6,7 @@ // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js // ==/UserScript== (async function () { @@ -17,22 +18,6 @@ const skipClickCheck = false; - let myResolve = () => { }; - const waitNext = async () => { - await new Promise((resolve) => { - myResolve = () => { setTimeout(resolve, 50) }; - }); - }; - - const waitActions = async (...messages) => { - if (skipClickCheck) return; - messages = messages.flat(); - for (const message of messages) { - console.log(message); - await waitNext(); - } - }; - const isInSubFrame = () => { try { @@ -73,153 +58,113 @@ // 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); - - 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" }); - - - // 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" }); - - // 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."); - - 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"); - - 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 { describe, it, itManual, expect, run } = SCTest.create({ name: "GM_registerMenuCommand 测试" }); - const p30 = GM_registerMenuCommand("MenuReg D-26", () => { + // skipClickCheck 沿用原义:跑注册逻辑与返回值断言,但跳过需要人点菜单的核对项。 + const manual = skipClickCheck ? () => {} : itManual; - console.log(130); - myResolve(); - }, { id: "2" }); - console.log("p30 === '2'", p30 === "2"); + describe("菜单 id 契约与交互", () => { + let r01, r02, p10, p20, p30, p32, p33, p34, p40, p50; + const obj1 = { id: "abc" }; - // MenuReg D-23 clicking shall give 110 - // MenuReg D-26 clicking shall give 130 - - 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 }); - console.log("p32 === 2", p32 === 2); - - // MenuReg D-23 clicking shall give 110 - // MenuReg D-26 clicking shall give 210 - - await waitActions("Click [MenuReg D-23] -> 110", "Click [MenuReg D-26] -> 210"); + it("同一 obj id 的两次注册都返回 'abc'", () => { + r01 = GM_registerMenuCommand("MenuReg abc-1", () => { console.log("abc-1"); }, obj1); + r02 = GM_registerMenuCommand("MenuReg abc-2", () => { console.log("abc-2"); }, obj1); + expect(r01).toBe("abc"); + expect(r02).toBe("abc"); + }); + manual("菜单里应只有 'MenuReg abc-2'", { + hint: "abc-1 与 abc-2 复用同一个 {id:'abc'},后者覆盖前者;打开扩展图标 → 本脚本菜单分组,应只见 MenuReg abc-2,点击它输出 abc-2", + }); - const p33 = GM_registerMenuCommand("MenuReg D-26", () => { + it("用新 id abd/abe 重新注册 abc-1/abc-2", () => { + GM_registerMenuCommand("MenuReg abc-1", () => { console.log("abc-1.abd"); }, { id: "abd" }); + GM_registerMenuCommand("MenuReg abc-2", () => { console.log("abc-2.abe"); }, { id: "abe" }); + }); - console.log(220); - myResolve(); - }, { id: 3 }); - console.log("p33 === 3", p33 === 3); + manual("菜单里应有 'MenuReg abc-2' 和 'MenuReg abc-1'", { + hint: "abd 重注册 abc-1、abe 重注册 abc-2;两项都应出现,点击分别输出 abc-1.abd 与 abc-2.abe", + }); - // MenuReg D-23 clicking shall give 110 - // MenuReg D-26 clicking shall give 210 220 + it("再用 abf + accessKey h 注册 abc-2", () => { + GM_registerMenuCommand("MenuReg abc-2", () => { console.log("abc-2.abf"); }, { id: "abf", accessKey: "h" }); + }); - await waitActions("Click [MenuReg D-23] -> 110", "Click [MenuReg D-26] -> 210, 220"); + manual("菜单里应有 abc-2、abc-1 和 abc-2 (H)", { + hint: "abf 带 accessKey h,显示为带 (H) 的第三项,点击输出 abc-2.abf", + }); + it("注销 abc/abd/abe/abf", () => { + GM_unregisterMenuCommand("abc"); + GM_unregisterMenuCommand("abd"); + GM_unregisterMenuCommand("abe"); + GM_unregisterMenuCommand("abf"); + }); + it("字符串第三参注册两次返回自增整数 1、2", () => { + p10 = GM_registerMenuCommand("MenuReg D-23", () => { console.log(110); }, "b"); + p20 = GM_registerMenuCommand("MenuReg D-23", () => { console.log(120); }, "b"); + expect(p10).toBe(1); + expect(p20).toBe(2); + }); - const p34 = GM_registerMenuCommand("MenuReg D-26", () => { + manual("点击 [MenuReg D-23] 应先后输出 110、120", { + hint: "第三参是字符串 'b'(accessKey),两次注册不合并;点该项看控制台", + }); - console.log(230); - myResolve(); - }, { id: "4" }); - console.log("p34 === '4'", p34 === "4"); + it("对象 id '2' 注册返回 '2'", () => { + p30 = GM_registerMenuCommand("MenuReg D-26", () => { console.log(130); }, { id: "2" }); + expect(p30).toBe("2"); + }); - // MenuReg D-23 clicking shall give 110 - // MenuReg D-26 clicking shall give 210 220 230 - await waitActions("Click [MenuReg D-23] -> 110", "Click [MenuReg D-26] -> 210, 220, 230"); + manual("[MenuReg D-23]→110,[MenuReg D-26]→130", { + hint: "分别点击两项核对控制台输出", + }); - GM_unregisterMenuCommand("4"); + it("数字 id 2 注册返回 2", () => { + p32 = GM_registerMenuCommand("MenuReg D-26", () => { console.log(210); }, { id: 2 }); + expect(p32).toBe(2); + }); + manual("[MenuReg D-23]→110,[MenuReg D-26]→210", { + hint: "id 2 与 '2' 视为同一项,210 覆盖 130;分别点击两项核对", + }); - // 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"); + it("数字 id 3 注册返回 3", () => { + p33 = GM_registerMenuCommand("MenuReg D-26", () => { console.log(220); }, { id: 3 }); + expect(p33).toBe(3); + }); + manual("[MenuReg D-23]→110,[MenuReg D-26]→210、220", { + hint: "id 3 是另一项,D-26 现有两个条目;分别点击核对", + }); + it("字符串 id '4' 注册返回 '4'", () => { + p34 = GM_registerMenuCommand("MenuReg D-26", () => { console.log(230); }, { id: "4" }); + expect(p34).toBe("4"); + }); - const p40 = GM_registerMenuCommand("MenuReg D-40", () => { + manual("[MenuReg D-23]→110,[MenuReg D-26]→210、220、230", { + hint: "id '4' 再加一项,D-26 现有三个条目;分别点击核对", + }); - console.log(601); - }); + it("注销 id '4'", () => { + GM_unregisterMenuCommand("4"); + }); - const p50 = GM_registerMenuCommand("MenuReg D-50", () => { + manual("[MenuReg D-23]→110,[MenuReg D-26]→210、220", { + hint: "id '4' 已注销,输出 230 的那项应消失", + }); - console.log(602); + it("无第三参注册返回自增整数(不断言具体值)", () => { + p40 = GM_registerMenuCommand("MenuReg D-40", () => { console.log(601); }); + p50 = GM_registerMenuCommand("MenuReg D-50", () => { console.log(602); }); + console.log("p40, p50", [p40, p50]); // TM gives 3&4 + }); }); - console.log("p40, p50", [p40, p50]); // TM gives 3&4 - - + await run(); })().finally(() => { console.log("finish"); }); - diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js index f473c66b0..965bec133 100644 --- a/example/tests/gm_xhr_cookie_test.js +++ b/example/tests/gm_xhr_cookie_test.js @@ -5,6 +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 // @connect mockhttp.org // @noframes // ==/UserScript== @@ -12,54 +13,10 @@ (async function () { "use strict"; - console.log( - "%c=== GM_xmlhttpRequest cookie 覆盖测试开始 ===", - "color: blue; font-size: 16px; font-weight: bold;" - ); + const { describe, it, expect, run } = SCTest.create({ name: "GM_xmlhttpRequest cookie 覆盖测试" }); const MOCKHTTP = "https://mockhttp.org"; - const testResults = { - passed: 0, - failed: 0, - total: 0, - }; - - async function test(name, fn) { - testResults.total++; - - try { - await fn(); - testResults.passed++; - console.log(`%c✓ ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%c✗ ${name}`, "color: red;", error); - return false; - } - } - - function assert(expected, actual, message) { - if (expected !== actual) { - const valueInfo = - `期望 ${JSON.stringify(expected)}, ` + - `实际 ${JSON.stringify(actual)}`; - - throw new Error( - message - ? `${message} - ${valueInfo}` - : `断言失败: ${valueInfo}` - ); - } - } - - function assertTrue(condition, message) { - if (!condition) { - throw new Error(message || "断言失败: 期望条件为真"); - } - } - function gmRequest(details) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ @@ -215,11 +172,7 @@ const actual = (map.get(name) || []).slice().sort(); const expected = expectedValues.slice().sort(); - assert( - JSON.stringify(expected), - JSON.stringify(actual), - message || `cookie "${name}" 的值集合` - ); + expect(JSON.stringify(actual)).toBe(JSON.stringify(expected)); } // ---------- Cookie 辅助函数 ---------- @@ -296,14 +249,13 @@ resetCookies(); setupCookies(); - try { - // ============ TM #2754 复现 ============ - console.log( - "\n%c--- TM #2754: 同名 cookie 应覆盖而非追加 ---", - "color: orange; font-weight: bold;" - ); + let lastCookieMap = null; + // 复刻原 `if (matrixRequestPassed && lastCookieMap)` 门控:矩阵请求失败时,其依赖用例应跳过 + // 而非各自级联失败(声明式框架无法条件注册用例,故用运行时 skip 表达同一依赖关系)。 + let matrixOk = false; - await test( + describe("TM #2754: 同名 cookie 应覆盖而非追加", () => { + it( "document.cookie=data=1,GM_xhr cookie: data=2 时应只送出 data=2", async () => { setCookie("data", "1", ROOT); @@ -331,14 +283,10 @@ } } ); + }); - // ============ TM #2829 复现 ============ - console.log( - "\n%c--- TM #2829: 多个不同名 cookie 不应被截断 ---", - "color: orange; font-weight: bold;" - ); - - await test( + describe("TM #2829: 多个不同名 cookie 不应被截断", () => { + it( 'GM_xhr cookie: "data1=1; data2=2" 应两者都送出,而非只剩第一个', async () => { const response = await gmRequest({ @@ -367,179 +315,118 @@ ); } ); + }); + + 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, + }); - // ============ 完整矩阵 ============ - console.log( - "\n%c--- 完整矩阵:浏览器已有(0/1/2) × 脚本指定(0/1/2) ---", - "color: orange; font-weight: bold;" - ); + const cookieHeader = getCookieHeader(response); - let lastCookieMap = null; + lastCookieMap = + parseCookieMultiMap(cookieHeader); - const matrixRequestPassed = await test( - "发送带完整矩阵 cookie 参数的请求", - async () => { - const customCookie = [ - "m01=new", - "m02=new1", - "m02=new2", - "m11=new", - "m12=new1", - "m12=new2", - "m21=new", - "m22=new1", - "m22=new2", - ].join("; "); + expect(cookieHeader.length > 0).toBeTruthy(); - const response = await gmRequest({ - method: "GET", - url: `${MOCKHTTP}/headers`, - cookie: customCookie, - timeout: 15000, - }); + expect(lastCookieMap.size > 0).toBeTruthy(); - const cookieHeader = getCookieHeader(response); - - lastCookieMap = - parseCookieMultiMap(cookieHeader); - - assertTrue( - cookieHeader.length > 0, - "应收到 Cookie header" - ); + matrixOk = true; + }); - assertTrue( - lastCookieMap.size > 0, - "Cookie header 应能成功解析" - ); - } - ); + it("m00:浏览器无、脚本未指定 → 不应出现", () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m00", []); + }); - if (matrixRequestPassed && lastCookieMap) { - await test( - "m00:浏览器无、脚本未指定 → 不应出现", - () => { - assertCookieValues(lastCookieMap, "m00", []); - } + it("m01:浏览器无、脚本指定单值 → 应为脚本值", () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues( + lastCookieMap, + "m01", + ["new"] ); + }); - await test( - "m01:浏览器无、脚本指定单值 → 应为脚本值", - () => { - assertCookieValues( - lastCookieMap, - "m01", - ["new"] - ); - } - ); + it("m02:浏览器无、脚本指定多值 → 应为脚本两个值", () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m02", [ + "new1", + "new2", + ]); + }); - await test( - "m02:浏览器无、脚本指定多值 → 应为脚本两个值", - () => { - assertCookieValues(lastCookieMap, "m02", [ - "new1", - "new2", - ]); - } + it("m10:浏览器单值、脚本未指定 → 应保留浏览器原值", () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues( + lastCookieMap, + "m10", + ["old"] ); + }); - await test( - "m10:浏览器单值、脚本未指定 → 应保留浏览器原值", - () => { - assertCookieValues( - lastCookieMap, - "m10", - ["old"] - ); - } + it("m11:浏览器单值、脚本指定单值 → 应覆盖为脚本值", () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues( + lastCookieMap, + "m11", + ["new"] ); + }); - await test( - "m11:浏览器单值、脚本指定单值 → 应覆盖为脚本值", - () => { - assertCookieValues( - lastCookieMap, - "m11", - ["new"] - ); - } - ); + it("m12:浏览器单值、脚本指定多值 → 应覆盖为脚本两个值", () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m12", [ + "new1", + "new2", + ]); + }); - await test( - "m12:浏览器单值、脚本指定多值 → 应覆盖为脚本两个值", - () => { - assertCookieValues(lastCookieMap, "m12", [ - "new1", - "new2", - ]); - } - ); + it("m20:浏览器多值(同名不同path)、脚本未指定 → 应保留浏览器全部值", () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m20", [ + "old1", + "old2", + ]); + }); - await test( - "m20:浏览器多值(同名不同path)、脚本未指定 → 应保留浏览器全部值", - () => { - assertCookieValues(lastCookieMap, "m20", [ - "old1", - "old2", - ]); - } + it("m21:浏览器多值、脚本指定单值 → 应完全覆盖为脚本单一值", () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues( + lastCookieMap, + "m21", + ["new"] ); + }); - await test( - "m21:浏览器多值、脚本指定单值 → 应完全覆盖为脚本单一值", - () => { - assertCookieValues( - lastCookieMap, - "m21", - ["new"] - ); - } - ); + it("m22:浏览器多值、脚本指定多值 → 应完全覆盖为脚本两个值", () => { + if (!matrixOk) SCTest.skip("矩阵请求未通过,跳过依赖断言"); + assertCookieValues(lastCookieMap, "m22", [ + "new1", + "new2", + ]); + }); + }); - await test( - "m22:浏览器多值、脚本指定多值 → 应完全覆盖为脚本两个值", - () => { - assertCookieValues(lastCookieMap, "m22", [ - "new1", - "new2", - ]); - } - ); - } else { - console.warn( - "%c完整矩阵请求失败,跳过依赖该请求结果的矩阵断言。", - "color: orange; font-weight: bold;" - ); - } + try { + await run(); } finally { resetCookies(); } - - // ============ 输出测试结果 ============ - console.log( - "\n%c=== 测试完成 ===", - "color: blue; font-size: 16px; font-weight: bold;" - ); - - console.log( - `%c总计: ${testResults.total} | ` + - `通过: ${testResults.passed} | ` + - `失败: ${testResults.failed}`, - testResults.failed === 0 - ? "color: green; font-weight: bold;" - : "color: red; font-weight: bold;" - ); - - if (testResults.failed === 0) { - console.log( - "%c🎉 所有测试通过!", - "color: green; font-size: 14px; font-weight: bold;" - ); - } else { - console.log( - "%c⚠️ 部分测试失败,请检查上面的错误信息", - "color: red; font-size: 14px; font-weight: bold;" - ); - } })(); diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js index a323653b9..5234ad05b 100644 --- a/example/tests/gm_xhr_redirect_test.js +++ b/example/tests/gm_xhr_redirect_test.js @@ -6,87 +6,17 @@ // @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 // @connect httpbingo.org // @noframes // ==/UserScript== const enableTool = true; -(function () { +(async function () { "use strict"; if (!enableTool) return; - // ---------- Panel ---------- - - const panel = document.createElement("div"); - panel.id = "gmxhr-test-panel"; - panel.innerHTML = ` - -
-
-
GM_xmlhttpRequest Test Harness
-
-
- - -
-
Status: idle
-
- `; - document.documentElement.append(panel); - - panel.querySelector("#ver").textContent = GM.info?.script?.version ?? ""; - panel.querySelector("#handler").textContent = `${GM.info?.scriptHandler} ${GM.info?.version}`; - - const $log = panel.querySelector("#log"); - const $counts = panel.querySelector("#counts"); - const $status = panel.querySelector("#status"); - - panel.querySelector("#clear").addEventListener("click", () => { - $log.textContent = ""; - setCounts(0, 0, 0); - $status.textContent = "Status: idle"; - }); - panel.querySelector("#start").addEventListener("click", runAll); - - function logLine(html) { - const el = document.createElement("div"); - el.innerHTML = html; - $log.prepend(el); - } - - function escapeHtml(s) { - return String(s).replace(/[&<>"']/g, m => - ({ "&":"&","<":"<",">":">",'"':""","'":"'" })[m]); - } - - const state = { pass: 0, fail: 0, skip: 0 }; - function setCounts(p, f, s) { $counts.textContent = `✅ ${p} ❌ ${f} ⏳ ${s}`; } - function pass(msg) { state.pass++; setCounts(state.pass, state.fail, state.skip); logLine(`✅ ${escapeHtml(msg)}`); } - function fail(msg, extra) { - state.fail++; setCounts(state.pass, state.fail, state.skip); - logLine(`❌ ${escapeHtml(msg)}${extra ? `
${escapeHtml(extra)}
` : ""}`); - } - function skip(msg) { state.skip++; setCounts(state.pass, state.fail, state.skip); logLine(`⏭️ ${escapeHtml(msg)}`); } + const { describe, it, expect, run } = SCTest.create({ name: "GM_xhr 重定向测试" }); // ---------- Request helper ---------- function gmRequest(details, { abortAfterMs } = {}) { @@ -106,11 +36,6 @@ const enableTool = true; const HB = "https://httpbingo.org"; - // ---------- Assertion utils ---------- - function assertEq(a, b, msg) { - if (a !== b) throw new Error(msg ? `${msg}: expected ${b}, got ${a}` : `expected ${b}, got ${a}`); - } - function objectProps(o) { if (!o || typeof o !== "object") return "not an object"; let z, oD, zD; @@ -130,23 +55,22 @@ const enableTool = true; 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 }); - assertEq(res.status, 200, "status 200"); - // httpbingo.org echoes query args as arrays (Go's url.Values shape). - assertEq(res.response?.args?.testing?.[0], "234", "response ok"); - assertEq(res.response?.args?.abc?.[0], "567", "response ok"); - assertEq(res.response?.url, `${HB}/get?testing=234&abc=567`, "response ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.response?.args?.testing?.[0]).toBe("234"); + expect(res.response?.args?.abc?.[0]).toBe("567"); + expect(res.response?.url).toBe(`${HB}/get?testing=234&abc=567`); + expect(objectProps(res)).toBe("ok"); }, }, { 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 }); - assertEq(res.status, 200, "status 200"); - assertEq(res.response?.args?.testing?.[0], "234", "response ok"); - assertEq(res.response?.args?.abc?.[0], "567", "response ok"); - assertEq(res.response?.url, `${HB}/get?abc=567&testing=234`, "response ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.response?.args?.testing?.[0]).toBe("234"); + expect(res.response?.args?.abc?.[0]).toBe("567"); + expect(res.response?.url).toBe(`${HB}/get?abc=567&testing=234`); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -154,9 +78,9 @@ const enableTool = true; async run(fetch) { const target = `${HB}/get?z=92`; const { res } = await gmRequest({ method: "GET", url: `${HB}/redirect-to?url=${encodeURIComponent(target)}`, fetch }); - assertEq(res.status, 200, "status after redirect is 200"); - assertEq(res.finalUrl, target, "finalUrl is redirected target"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.finalUrl).toBe(target); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -164,9 +88,9 @@ const enableTool = true; 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 }); - assertEq(res.status, 200, "status after redirect is 200"); - assertEq(res.finalUrl, target, "finalUrl is redirected target"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.finalUrl).toBe(target); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -179,11 +103,11 @@ const enableTool = true; ]); throw new Error("Expected error, got load"); } catch (e) { - assertEq(e?.kind, "error", "error ok"); - assertEq(e?.res?.status, 408, "statusCode ok"); - assertEq(!e?.res?.finalUrl, true, "!finalUrl ok"); - assertEq(e?.res?.responseHeaders, "", "responseHeaders ok"); - assertEq(objectProps(e?.res), "ok", "Object Props OK"); + expect(e?.kind).toBe("error"); + expect(e?.res?.status).toBe(408); + expect(!e?.res?.finalUrl).toBe(true); + expect(e?.res?.responseHeaders).toBe(""); + expect(objectProps(e?.res)).toBe("ok"); } }, }, @@ -195,10 +119,10 @@ const enableTool = true; gmRequest({ method: "GET", url, redirect: "manual", fetch }), new Promise(resolve => setTimeout(resolve, 4000)), ]); - assertEq(res?.status, 301, "status is 301"); - assertEq(res?.finalUrl, url, "finalUrl is original url"); - assertEq(typeof res?.responseHeaders === "string" && res?.responseHeaders !== "", true, "responseHeaders ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res?.status).toBe(301); + expect(res?.finalUrl).toBe(url); + expect(typeof res?.responseHeaders).toBe("string"); + expect(objectProps(res)).toBe("ok"); }, }, ]; @@ -208,38 +132,12 @@ const enableTool = true; ...basicTests.map(t => ({ ...t, useFetch: true })), ]; - // ---------- Runner ---------- - function fmtMs(ms) { return ms < 1000 ? `${ms | 0}ms` : `${(ms / 1000).toFixed(2)}s`; } - - async function runAll() { - state.pass = state.fail = state.skip = 0; - setCounts(0, 0, 0); - logLine(`Starting GM_xmlhttpRequest test suite — ${new Date().toLocaleString()}`); - - for (let i = 0; i < tests.length; i++) { - const t = tests[i]; - const tName = `${t.useFetch ? "[fetch]" : "[xhr]"} ${t.name}`; - $status.textContent = `Status: running (${i + 1}/${tests.length}): ${tName}`; - logLine(`▶️ ${escapeHtml(tName)}`); - const t0 = performance.now(); - try { - await t.run(t.useFetch ? true : false); - pass(`• ${tName} (${fmtMs(performance.now() - t0)})`); - } catch (e) { - console.error(e); - const stack = e?.stack ? e.stack.split("\n").slice(0, 4).join("\n") : null; - fail(`• ${tName} (${fmtMs(performance.now() - t0)})`, [e?.message, stack].filter(Boolean).join("\n")); - } + describe("GM_xmlhttpRequest 重定向", () => { + for (const t of tests) { + const label = `${t.useFetch ? "[fetch] " : "[xhr] "}${t.name}`; + it(label, () => t.run(t.useFetch ? true : false)); } + }); - $status.textContent = "Status: done"; - logLine(`Done. ✅ ${state.pass} ❌ ${state.fail} ⏳ ${state.skip}`); - } - - setTimeout(() => { - if (!window.__gmxhr_test_autorun__) { - window.__gmxhr_test_autorun__ = true; - runAll(); - } - }, 600); -})(); \ No newline at end of file + await run(); +})(); diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index 2e0e1f89c..86a59fb55 100644 --- a/example/tests/gm_xhr_test.js +++ b/example/tests/gm_xhr_test.js @@ -6,6 +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 // @connect httpbingo.org // @connect nonexistent-domain-abcxyz.test // @connect raw.githubusercontent.com @@ -16,13 +17,11 @@ /* WHAT THIS DOES -------------- - - Builds an in-page test runner panel. - Runs a battery of tests probing GM_xmlhttpRequest options, callbacks, and edge/abnormal paths. - Uses httpbin.org endpoints for deterministic echo/response behavior. - Prints a summary and a detailed per-test log with assertions. - NOTE: Endpoints now point to https://httpbingo.org (open source, https://github.com/mccutchen/httpbingo.org, - backed by https://github.com/mccutchen/go-httpbin — httpbin-compatible). + NOTE: Endpoints now point to https://httpbingo.org (a faster httpbin-like service). See https://httpbingo.org for docs and exact paths. (Also supports /get, /post, /bytes/{n}, /delay/{s}, /status/{code}, /redirect-to, /headers, /any, etc.) */ @@ -46,21 +45,11 @@ */ const enableTool = true; -(function () { +(async function () { "use strict"; if (!enableTool) return; - // ---------- Small DOM helper ---------- - function h(tag, props = {}, ...children) { - const el = document.createElement(tag); - Object.entries(props).forEach(([k, v]) => { - if (k === "style" && typeof v === "object") Object.assign(el.style, v); - else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v); - else el[k] = v; - }); - for (const c of children) el.append(c && c.nodeType ? c : document.createTextNode(String(c))); - return el; - } + const { describe, it, expect, run } = SCTest.create({ name: "GM_xmlhttpRequest 完整测试" }); // value type helper const typing = (x) => { @@ -88,335 +77,7 @@ const enableTool = true; const isFirefox = typeof mozInnerScreenX === "number"; - // ---------- Test Panel ---------- - const panel = h( - "div", - { - id: "gmxhr-test-panel", - style: { - position: "fixed", - bottom: "12px", - right: "12px", - width: "460px", - maxHeight: "70vh", - overflow: "auto", - zIndex: 2147483647, - 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)", - border: "1px solid #333", - }, - }, - h( - "div", - { - style: { - position: "sticky", - top: 0, - background: "#181818", - padding: "10px 12px", - borderBottom: "1px solid #333", - display: "flex", - alignItems: "center", - gap: "8px", - }, - }, - h("div", {}, h("div", { style: { fontWeight: "500" } }, `GM_xmlhttpRequest Test Harness ${GM.info?.script?.version}`), h("div", { style: { display: "flex", flexDirection: "row" } }, h("div", { style: { fontWeight: "400" } }, `${GM.info?.scriptHandler} ${GM.info?.version}`), h("div", { id: "counts", style: { marginLeft: "auto", opacity: 0.8 } }, "…"))), - h("button", { id: "start", style: btn() }, "Run"), - h("button", { id: "clear", style: btn() }, "Clear") - ), - // Added: live status + pending queue (minimal UI) - h( - "div", - { id: "status", style: { padding: "6px 12px", borderBottom: "1px solid #222", opacity: 0.9 } }, - "Status: idle" - ), - h( - "details", - { id: "queueWrap", open: false, style: { padding: "0 12px 6px", borderBottom: "1px solid #222" } }, - h("summary", {}, "Pending tests"), - h( - "div", - { - id: "queue", - style: { - fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace", - whiteSpace: "pre-wrap", - opacity: 0.8, - }, - }, - "(none)" - ) - ), - h("div", { id: "log", style: { padding: "10px 12px" } }) - ); - document.documentElement.append(panel); - - function btn() { - return { - background: "#2a6df1", - color: "white", - border: "0", - padding: "6px 10px", - borderRadius: "6px", - cursor: "pointer", - }; - } - - const $log = panel.querySelector("#log"); - const $counts = panel.querySelector("#counts"); - const $status = panel.querySelector("#status"); - const $queue = panel.querySelector("#queue"); - panel.querySelector("#clear").addEventListener("click", () => { - $log.textContent = ""; - setCounts(0, 0, 0); - setStatus("idle"); - setQueue([]); - }); - panel.querySelector("#start").addEventListener("click", runAll); - - function logLine(html, cls = "") { - const line = h("div", { style: { padding: "6px 0", borderBottom: "1px dashed #2a2a2a" } }); - line.innerHTML = html; - if (cls) line.className = cls; - $log.prepend(line); - } - - function setCounts(p, f, s) { - $counts.textContent = `✅ ${p} ❌ ${f} ⏳ ${s}`; - } - function setStatus(text) { - $status.textContent = `Status: ${text}`; - } - function setQueue(items) { - $queue.textContent = items.length ? items.map((t, i) => `${i + 1}. ${t}`).join("\n") : "(none)"; - } - - // ---------- Pretty Stack ----------- - function prettyStack(errorOrStack, options = {}) { - const { - stripQuery = true, - decode = true, - maxUrlLength = 90, - dropExtensionUuid = true, - pathSegments = 2, - indent = " ", - minFnWidth = 8, - maxFnWidth = 48, - minLocWidth = 5, - maxLocWidth = 12, - maxLines = -1, - } = options; - - const rawStack = - typeof errorOrStack === "string" - ? errorOrStack - : errorOrStack && errorOrStack.stack - ? errorOrStack.stack - : String(errorOrStack); - - const lines = rawStack.split(/\r?\n/).filter(Boolean); - - const frames = lines - .filter((line, j) => maxLines > 0 ? j < maxLines : true) - .map(parseStackLine) - .filter(Boolean) - .map(frame => ({ - ...frame, - fn: cleanFunctionName(frame.fn), - file: cleanFileName(frame.file, { - stripQuery, - decode, - maxUrlLength, - dropExtensionUuid, - pathSegments, - }), - })); - - if (!frames.length) return rawStack; - - const fnWidth = clamp( - frames.reduce((max, f) => Math.max(max, f.fn.length), minFnWidth), - minFnWidth, - maxFnWidth - ); - - const locWidth = clamp( - frames.reduce((max, f) => { - return Math.max(max, `${f.line}:${f.col}`.length); - }, minLocWidth), - minLocWidth, - maxLocWidth - ); - - return frames - .map(f => { - const fn = padRight(truncateMiddle(f.fn, fnWidth), fnWidth); - const loc = padLeft(`${f.line}:${f.col}`, locWidth); - return `${indent}${fn} ${loc} ${f.file}`; - }) - .join("\n"); - } - - function parseStackLine(line) { - const s = line.trim(); - - // Chrome / V8: - // at fn (file:line:col) - // - // Examples: - // at run (https://example.com/app.js:10:5) - // at async runAll (https://example.com/app.js:20:9) - // at new Foo (https://example.com/app.js:30:11) - let m = s.match(/^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/); - if (m) { - return { - fn: m[1], - file: m[2], - line: Number(m[3]), - col: Number(m[4]), - }; - } - - // Chrome / V8 anonymous: - // at file:line:col - m = s.match(/^at\s+(.+):(\d+):(\d+)$/); - if (m) { - return { - fn: "", - file: m[1], - line: Number(m[2]), - col: Number(m[3]), - }; - } - - // Firefox: - // fn@file:line:col - // async*fn@file:line:col - // setTimeout handler*fn@file:line:col - // - // Use a greedy file capture so the last two numeric groups win. - m = s.match(/^(.*?)@(.+):(\d+):(\d+)$/); - if (m) { - return { - fn: m[1] || "", - file: m[2], - line: Number(m[3]), - col: Number(m[4]), - }; - } - - return null; - } - - function cleanFunctionName(fn) { - let s = String(fn).trim(); - - if (!s) return ""; - if (s === "") return s; - - return s - .replace(/^async\*/, "async ") - .replace(/^setTimeout handler\*/, "timer ") - .replace(/^promise callback\*/, "promise ") - .replace(/\["#-[^"]+"\]/g, "[userscript]") - .replace(/\/+/g, "") - .replace(/\s+/g, " ") - .trim() || ""; - } - - function cleanFileName(file, options) { - let s = String(file); - - if (options.stripQuery) { - s = s.replace(/[?#].*$/, ""); - } - - if (options.dropExtensionUuid) { - s = s.replace( - /^(?:moz|chrome)-extension:\/\/[^/]+\//, - "extension://" - ); - } - - let parts = s.split("/"); - - if (options.pathSegments > 0) { - parts = parts.slice(-options.pathSegments); - } - - if (options.decode) { - parts = parts.map(part => { - try { - return decodeURIComponent(part); - } catch { - return part; - } - }); - } - - s = parts.join("/"); - - return truncateMiddle(s, options.maxUrlLength); - } - - function truncateMiddle(value, max) { - const str = String(value); - - if (!Number.isFinite(max) || max <= 0) return ""; - if (str.length <= max) return str; - if (max <= 3) return str.slice(0, max); - - const available = max - 1; - const left = Math.ceil(available / 2); - const right = Math.floor(available / 2); - - return `${str.slice(0, left)}…${str.slice(-right)}`; - } - - function padRight(value, width) { - return String(value).padEnd(width, " "); - } - - function padLeft(value, width) { - return String(value).padStart(width, " "); - } - - function clamp(value, min, max) { - return Math.min(Math.max(value, min), max); - } - - // ---------- Assertion & request helpers ---------- - const state = { pass: 0, fail: 0, skip: 0 }; - function pass(msg) { - state.pass++; - setCounts(state.pass, state.fail, state.skip); - logLine(`✅ ${escapeHtml(msg)}`); - } - function fail(msg, extra) { - state.fail++; - setCounts(state.pass, state.fail, state.skip); - logLine( - `❌ ${escapeHtml(msg)}${extra ? `
${escapeHtml(extra)}
` : ""}`, - "fail" - ); - } - function skip(msg) { - state.skip++; - setCounts(state.pass, state.fail, state.skip); - logLine(`⏭️ ${escapeHtml(msg)}`, "skip"); - } - - function escapeHtml(s) { - return String(s).replace( - /[&<>"']/g, - (m) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[m] - ); - } - + // ---------- Request helper ---------- function gmRequest(details, { abortAfterMs } = {}) { return new Promise((resolve, reject) => { const t0 = performance.now(); @@ -440,13 +101,13 @@ const enableTool = true; }); } - // Switched base host to httpbingo.org (open source, go-httpbin-backed, httpbin-compatible). + // Switched base host from httpbin to httpbingo (faster). // See: https://httpbingo.org (endpoints: /get, /post, /bytes/{n}, /delay/{s}, /status/{code}, /redirect-to, /headers, /any, etc.) const HB = "https://httpbingo.org"; - // Helper: handle minor schema diffs across httpbin-family servers for query echo + // Helper: handle minor schema diffs between httpbin/httpbingo for query echo function getQueryObj(body) { - // httpbin/httpbingo use "args" (and some httpbin-likes use "query" for compatibility). + // httpbin uses "args", httpbingo may use "query" (and still often provides "args" for compatibility). return body.args || body.query || body.params || {}; } @@ -466,11 +127,11 @@ const enableTool = true; url, fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText, decodedBase64, "responseText ok"); - assertEq(res.response, decodedBase64, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText).toBe(decodedBase64); + expect(res.response).toBe(decodedBase64); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -483,11 +144,11 @@ const enableTool = true; responseType: "", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText, decodedBase64, "responseText ok"); - assertEq(res.response, decodedBase64, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText).toBe(decodedBase64); + expect(res.response).toBe(decodedBase64); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -500,11 +161,11 @@ const enableTool = true; responseType: "text", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText, decodedBase64, "responseText ok"); - assertEq(res.response, decodedBase64, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText).toBe(decodedBase64); + expect(res.response).toBe(decodedBase64); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, @@ -518,11 +179,11 @@ const enableTool = true; responseType: "json", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText, decodedBase64, "responseText ok"); - assertEq(res.response, undefined, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText).toBe(decodedBase64); + expect(res.response).toBe(undefined); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -535,11 +196,11 @@ const enableTool = true; responseType: "document", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText, decodedBase64, "responseText ok"); - assertEq(res.response instanceof XMLDocument, true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText).toBe(decodedBase64); + expect(res.response instanceof XMLDocument).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -552,11 +213,11 @@ const enableTool = true; responseType: "stream", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText, undefined, "responseText ok"); - assertEq(res.response instanceof ReadableStream, true, "response ok"); - assertEq(res.responseXML, undefined, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText).toBe(undefined); + expect(res.response instanceof ReadableStream).toBe(true); + expect(res.responseXML).toBe(undefined); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -569,11 +230,11 @@ const enableTool = true; responseType: "arraybuffer", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText, decodedBase64, "responseText ok"); - assertEq(res.response instanceof ArrayBuffer, true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText).toBe(decodedBase64); + expect(res.response instanceof ArrayBuffer).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -586,11 +247,11 @@ const enableTool = true; responseType: "blob", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText, decodedBase64, "responseText ok"); - assertEq(res.response instanceof Blob, true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText).toBe(decodedBase64); + expect(res.response instanceof Blob).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -602,11 +263,11 @@ const enableTool = true; url, fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"method": "GET"'), true, "responseText ok"); - assertEq(`${res.response}`.includes('"method": "GET"'), true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(`${res.responseText}`.includes('"method": "GET"')).toBe(true); + expect(`${res.response}`.includes('"method": "GET"')).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -619,11 +280,11 @@ const enableTool = true; responseType: "", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"method": "GET"'), true, "responseText ok"); - assertEq(`${res.response}`.includes('"method": "GET"'), true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(`${res.responseText}`.includes('"method": "GET"')).toBe(true); + expect(`${res.response}`.includes('"method": "GET"')).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -636,11 +297,11 @@ const enableTool = true; responseType: "text", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"method": "GET"'), true, "responseText ok"); - assertEq(`${res.response}`.includes('"method": "GET"'), true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(`${res.responseText}`.includes('"method": "GET"')).toBe(true); + expect(`${res.response}`.includes('"method": "GET"')).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -653,11 +314,11 @@ const enableTool = true; responseType: "json", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"method": "GET"'), true, "responseText ok"); - assertEq(typeof res.response === "object" && res.response?.method === "GET", true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(`${res.responseText}`.includes('"method": "GET"')).toBe(true); + expect(typeof res.response === "object" && res.response?.method === "GET").toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -670,11 +331,11 @@ const enableTool = true; responseType: "document", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"method": "GET"'), true, "responseText ok"); - assertEq(res.response instanceof XMLDocument, true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(`${res.responseText}`.includes('"method": "GET"')).toBe(true); + expect(res.response instanceof XMLDocument).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -687,11 +348,11 @@ const enableTool = true; responseType: "stream", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText, undefined, "responseText ok"); - assertEq(res.response instanceof ReadableStream, true, "response ok"); - assertEq(res.responseXML, undefined, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText).toBe(undefined); + expect(res.response instanceof ReadableStream).toBe(true); + expect(res.responseXML).toBe(undefined); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -704,11 +365,11 @@ const enableTool = true; responseType: "arraybuffer", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"method": "GET"'), true, "responseText ok"); - assertEq(res.response instanceof ArrayBuffer, true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(`${res.responseText}`.includes('"method": "GET"')).toBe(true); + expect(res.response instanceof ArrayBuffer).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -721,11 +382,11 @@ const enableTool = true; responseType: "blob", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"method": "GET"'), true, "responseText ok"); - assertEq(res.response instanceof Blob, true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(`${res.responseText}`.includes('"method": "GET"')).toBe(true); + expect(res.response instanceof Blob).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -737,11 +398,11 @@ const enableTool = true; url, fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); - assertEq(res.response, res.responseText, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText?.length >= 8 && res.responseText?.length <= 32).toBe(true); + expect(res.response).toBe(res.responseText); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -754,11 +415,11 @@ const enableTool = true; responseType: "", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); - assertEq(res.response, res.responseText, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText?.length >= 8 && res.responseText?.length <= 32).toBe(true); + expect(res.response).toBe(res.responseText); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -771,11 +432,11 @@ const enableTool = true; responseType: "text", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); - assertEq(res.response, res.responseText, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText?.length >= 8 && res.responseText?.length <= 32).toBe(true); + expect(res.response).toBe(res.responseText); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -788,11 +449,11 @@ const enableTool = true; responseType: "json", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); - assertEq(res.response, undefined, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText?.length >= 8 && res.responseText?.length <= 32).toBe(true); + expect(res.response).toBe(undefined); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -805,11 +466,11 @@ const enableTool = true; responseType: "document", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); - assertEq(res.response instanceof XMLDocument, true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText?.length >= 8 && res.responseText?.length <= 32).toBe(true); + expect(res.response instanceof XMLDocument).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -822,11 +483,11 @@ const enableTool = true; responseType: "stream", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText, undefined, "responseText ok"); - assertEq(res.response instanceof ReadableStream, true, "response ok"); - assertEq(res.responseXML, undefined, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText).toBe(undefined); + expect(res.response instanceof ReadableStream).toBe(true); + expect(res.responseXML).toBe(undefined); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -839,11 +500,11 @@ const enableTool = true; responseType: "arraybuffer", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); - assertEq(res.response instanceof ArrayBuffer, true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText?.length >= 8 && res.responseText?.length <= 32).toBe(true); + expect(res.response instanceof ArrayBuffer).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -856,11 +517,11 @@ const enableTool = true; responseType: "blob", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); - assertEq(res.response instanceof Blob, true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText?.length >= 8 && res.responseText?.length <= 32).toBe(true); + expect(res.response instanceof Blob).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -874,15 +535,14 @@ const enableTool = true; fetch, }); const body = JSON.parse(res.responseText); - assertEq(res.status, 200, "status 200"); - // httpbingo.org echoes query args and headers as arrays (Go's url.Values shape). + expect(res.status).toBe(200); const q = getQueryObj(body); - assertEq(q.x?.[0] ?? q.x, "1", "query args"); + expect(q.x?.[0] ?? q.x).toBe("1"); const hdrs = body.headers || {}; const customHeader = hdrs["X-Custom"] || hdrs["x-custom"]; - assertEq(Array.isArray(customHeader) ? customHeader[0] : customHeader, "Hello", "custom header echo"); - assertEq(res.finalUrl, url, "finalUrl matches"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(Array.isArray(customHeader) ? customHeader[0] : customHeader).toBe("Hello"); + expect(res.finalUrl).toBe(url); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -895,9 +555,9 @@ const enableTool = true; url, fetch, }); - assertEq(res.status, 200, "status after redirect is 200"); - assertEq(res.finalUrl, target, "finalUrl is redirected target"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.finalUrl).toBe(target); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -911,9 +571,9 @@ const enableTool = true; redirect: "follow", fetch, }); - assertEq(res.status, 200, "status after redirect is 200"); - assertEq(res.finalUrl, target, "finalUrl is redirected target"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.finalUrl).toBe(target); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -935,11 +595,11 @@ const enableTool = true; ]); throw new Error("Expected error, got load"); } catch (e) { - assertEq(e?.kind, "error", "error ok"); - assertEq(e?.res?.status, 408, "statusCode ok"); - assertEq(!e?.res?.finalUrl, true, "!finalUrl ok"); - assertEq(e?.res?.responseHeaders, "", "responseHeaders ok"); - assertEq(objectProps(e?.res), "ok", "Object Props OK"); + expect(e?.kind).toBe("error"); + expect(e?.res?.status).toBe(408); + expect(!e?.res?.finalUrl).toBe(true); + expect(e?.res?.responseHeaders).toBe(""); + expect(objectProps(e?.res)).toBe("ok"); } }, }, @@ -958,17 +618,10 @@ const enableTool = true; }), new Promise((resolve) => setTimeout(resolve, 4000)), ]); - assertEq(res?.status, 301, "status is 301"); - assertEq(res?.finalUrl, url, "finalUrl is original url"); - // Any `redirect` option (including "manual") makes the manager use the Fetch API - // internally regardless of the xhr/fetch toggle here, so both modes hit the browser's - // native opaqueredirect Response - whose headers are empty by spec (Fetch spec - // §opaque-redirect filtered response), confirmed directly against httpbingo.org with a - // fresh (no-store) fetch(): res.type === "opaqueredirect", res.headers has 0 entries, - // every time. This is a manager/browser limitation, not an httpbingo.org gap, so only - // check the type here rather than requiring a non-empty value. - assertEq(typeof res?.responseHeaders === "string", true, "responseHeaders ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res?.status).toBe(301); + expect(res?.finalUrl).toBe(url); + expect(typeof res?.responseHeaders).toBe("string"); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -983,11 +636,10 @@ const enableTool = true; fetch, }); const body = JSON.parse(res.responseText); - assertEq(res.status, 200); - // httpbingo.org echoes form values as arrays (Go's url.Values shape). - assertEq((body.form?.a || [])[0], "1", "form a"); - assertEq((body.form?.b || [])[0], "two", "form b"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect((body.form?.a || [])[0]).toBe("1"); + expect((body.form?.b || [])[0]).toBe("two"); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1002,9 +654,9 @@ const enableTool = true; fetch, }); const body = JSON.parse(res.responseText); - assertEq(res.status, 200); - assertDeepEq(body.json, payload, "JSON echo matches"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(body.json).toEqual(payload); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1019,9 +671,9 @@ const enableTool = true; fetch, }); const body = JSON.parse(res.responseText); - assertEq(res.status, 200); - assert(body.data && body.data.length > 0, "server received some data"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(body.data && body.data.length > 0).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1038,11 +690,11 @@ const enableTool = true; }, fetch, }); - assertEq(res.status, 200); - assert(res.response instanceof ArrayBuffer, "arraybuffer present"); - assertEq(res.response.byteLength, size, "byte length matches"); - assert(progressCounter >= 1, "progressCounter >= 1"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.response instanceof ArrayBuffer).toBeTruthy(); + expect(res.response.byteLength).toBe(size); + expect(progressCounter >= 1).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1060,12 +712,12 @@ const enableTool = true; }, fetch, }); - assertEq(res.status, 200); - assert(res.response instanceof Blob, "blob present"); + expect(res.status).toBe(200); + expect(res.response instanceof Blob).toBeTruthy(); const buf = await res.response.arrayBuffer(); - assertEq(buf.byteLength, size, "byte length matches"); - assert(progressCounter >= 1, "progressCounter >= 1"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(buf.byteLength).toBe(size); + expect(progressCounter >= 1).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); // Do not assert image MIME; httpbingo returns octet-stream here. }, }, @@ -1079,10 +731,10 @@ const enableTool = true; responseType: "json", fetch, }); - assertEq(res.status, 200); - assert(res.response && typeof res.response === "object", "parsed JSON object"); - assert(res.response.origin, "has JSON fields"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.response && typeof res.response === "object").toBeTruthy(); + expect(res.response.origin).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1094,10 +746,10 @@ const enableTool = true; responseType: "document", fetch, }); - assertEq(res.status, 200); - assert(res.response instanceof Document, "xml present"); - assert(res.responseXML !== null, "xml OK"); - assert(!!res.responseXML.querySelector("test-123"), "xml content ok"); + expect(res.status).toBe(200); + expect(res.response instanceof Document).toBeTruthy(); + expect(res.responseXML !== null).toBeTruthy(); + expect(!!res.responseXML.querySelector("test-123")).toBeTruthy(); }, }, { @@ -1109,10 +761,10 @@ const enableTool = true; responseType: "document", fetch, }); - assertEq(res.status, 200); - assert(res.response instanceof Document, "xml present"); - assert(res.responseXML !== null, "xml OK"); - assert(!!res.responseXML.querySelector("parsererror"), "xml content ok"); + expect(res.status).toBe(200); + expect(res.response instanceof Document).toBeTruthy(); + expect(res.responseXML !== null).toBeTruthy(); + expect(!!res.responseXML.querySelector("parsererror")).toBeTruthy(); }, }, { @@ -1124,9 +776,9 @@ const enableTool = true; overrideMimeType: "text/plain;charset=utf-8", fetch, }); - assertEq(res.status, 200); - assert(typeof res.responseText === "string" && res.responseText.length > 0, "responseText available"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(typeof res.responseText === "string" && res.responseText.length > 0).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1141,7 +793,7 @@ const enableTool = true; }); throw new Error("Expected timeout, got load"); } catch (e) { - assertEq(e.kind, "timeout", "timeout path taken"); + expect(e.kind).toBe("timeout"); } }, }, @@ -1187,14 +839,14 @@ const enableTool = true; const normal = await runCase({ url: `${HB}/get`, }); - assertDeepEq(normal.events, ["onload", "onloadend"], "normal fires onload then onloadend"); - assertEq(normal.response.status, 200, "normal onloadend status 200"); + expect(normal.events).toEqual(["onload", "onloadend"]); + expect(normal.response.status).toBe(200); const timeout = await runCase({ url: `${HB}/delay/5`, timeout: 2000, }); - assertDeepEq(timeout.events, ["ontimeout", "onloadend"], "timeout fires ontimeout then onloadend"); + expect(timeout.events).toEqual(["ontimeout", "onloadend"]); const abort = await runCase( { @@ -1202,7 +854,7 @@ const enableTool = true; }, { abortAfterMs: 4000 } ); - assertDeepEq(abort.events, ["onabort", "onloadend"], "abort fires onabort then onloadend"); + expect(abort.events).toEqual(["onabort", "onloadend"]); const nwError1 = await runCase( { @@ -1210,7 +862,7 @@ const enableTool = true; }, { abortAfterMs: 500 } ); - assertDeepEq(nwError1.events, ["onerror", "onloadend"], "abort fires onerror then onloadend"); + expect(nwError1.events).toEqual(["onerror", "onloadend"]); const nwError2 = await runCase( { @@ -1218,7 +870,7 @@ const enableTool = true; }, { abortAfterMs: 500 } ); - assertDeepEq(nwError2.events, ["onerror", "onloadend"], "abort fires onerror then onloadend"); + expect(nwError2.events).toEqual(["onerror", "onloadend"]); }, }, { @@ -1232,17 +884,11 @@ const enableTool = true; const start = performance.now(); GM_xmlhttpRequest({ method: "GET", - // Native XMLHttpRequest only surfaces onprogress once the response has accumulated - // enough bytes to cross its internal flush threshold (~1KB was observed to fire only - // once end-to-end in real Chrome; 2KB reliably produced dozens of events across - // repeated runs), unlike the Fetch API's ReadableStream reader, which reports every - // chunk as it arrives regardless of size. Use 2KB so both modes clear the >=4 bar. url: `${HB}/drip?duration=2&delay=1&numbytes=2048`, // ~2KB responseType: "arraybuffer", onprogress: (ev) => { progressEvents++; if (ev.loaded != null) lastLoaded = ev.loaded; - setStatus(`downloading: ${lastLoaded | 0} bytes…`); response = ev.response; }, onload: (res) => resolve({ res, ms: performance.now() - start }), @@ -1251,11 +897,11 @@ const enableTool = true; fetch, }); }); - assertEq(res.status, 200); - assert(progressEvents >= 4, "received at least 4 progress events"); - assert(lastLoaded > 0, "progress loaded captured"); - assert(!response, "no response"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(progressEvents >= 4).toBeTruthy(); + expect(lastLoaded > 0).toBeTruthy(); + expect(!response).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1269,7 +915,7 @@ const enableTool = true; const start = performance.now(); GM_xmlhttpRequest({ method: "GET", - url: `${HB}/drip?duration=2&delay=1&numbytes=2048`, // ~2KB, see sibling [arraybuffer] test above + url: `${HB}/drip?duration=2&delay=1&numbytes=2048`, // ~2KB responseType: "stream", onloadstart: async (ev) => { const reader = ev.response?.getReader(); @@ -1281,7 +927,6 @@ const enableTool = true; progressEvents++; loaded += value.length; if (loaded != null) lastLoaded = loaded; - setStatus(`downloading: ${loaded | 0} bytes…`); response = ev.response; } if (done) break; @@ -1294,11 +939,11 @@ const enableTool = true; fetch, }); }); - assertEq(res.status, 200); - assert(progressEvents >= 4, "received at least 4 progress events"); - assert(lastLoaded > 0, "progress loaded captured"); - assert(response instanceof ReadableStream && typeof response.getReader === "function", "response"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(progressEvents >= 4).toBeTruthy(); + expect(lastLoaded > 0).toBeTruthy(); + expect(response instanceof ReadableStream && typeof response.getReader === "function").toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1309,10 +954,10 @@ const enableTool = true; url: `${HB}/response-headers`, fetch, }); - assertEq(res.status, 200); - assert((res.responseText || "")?.length > 0, "body for HEAD"); - assert(typeof res.responseHeaders === "string", "response headers present"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect((res.responseText || "")?.length > 0).toBeTruthy(); + expect(typeof res.responseHeaders === "string").toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1323,10 +968,10 @@ const enableTool = true; url: `${HB}/response-headers`, fetch, }); - assertEq(res.status, 200); - assertEq(res.responseText || "", "", "no body for HEAD"); - assert(typeof res.responseHeaders === "string", "response headers present"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(res.responseText || "").toBe(""); + expect(typeof res.responseHeaders === "string").toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1338,8 +983,8 @@ const enableTool = true; fetch, }); // httpbingo commonly returns 200 for OPTIONS - assert(res.status === 200 || res.status === 204, "200/204 on OPTIONS"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status === 200 || res.status === 204).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1350,10 +995,10 @@ const enableTool = true; url: `${HB}/delete`, fetch, }); - assertEq(res.status, 200); + expect(res.status).toBe(200); const body = JSON.parse(res.responseText); - assertEq(body.method, "DELETE", "server saw DELETE"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(body.method).toBe("DELETE"); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1376,11 +1021,11 @@ const enableTool = true; url: `${HB}/cookies`, fetch, }); - assertEq(res.status, 200); + expect(res.status).toBe(200); const body = JSON.parse(res.responseText); const cookieABC = body.cookies.abc; - assertEq(cookieABC, "123", "cookie abc=123"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(cookieABC).toBe("123"); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1395,8 +1040,8 @@ const enableTool = true; }); const body = JSON.parse(res.responseText); const cookies = body.headers.Cookie || body.headers.cookie; - assert(!`${cookies}`.includes("abc=123"), "no Cookie header when anonymous"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(!`${cookies}`.includes("abc=123")).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1410,8 +1055,8 @@ const enableTool = true; }); const body = JSON.parse(res.responseText); const cookies = body.headers.Cookie || body.headers.cookie; - assert(`${cookies}`.includes("abc=123"), "Cookie header"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(`${cookies}`.includes("abc=123")).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1450,8 +1095,8 @@ const enableTool = true; }); const body = JSON.parse(res.responseText); const cookies = body.headers.Cookie || body.headers.cookie; - assert(!cookies, "no Cookie header when anonymous"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(!cookies).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1478,11 +1123,11 @@ const enableTool = true; password: pass, fetch, }); - assertEq(res.status, 200); + expect(res.status).toBe(200); const body = JSON.parse(res.responseText); - assertEq(body.authenticated, true, "authenticated true"); - assertEq(body.user, "user", "user echoed"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(body.authenticated).toBe(true); + expect(body.user).toBe("user"); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1493,8 +1138,8 @@ const enableTool = true; url: `${HB}/status/418`, fetch, }); - assertEq(res.status, 418, "418 I'm a teapot"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(418); + expect(objectProps(res)).toBe("ok"); // Still triggers onload, not onerror }, }, @@ -1507,8 +1152,8 @@ const enableTool = true; url: `${HB}/headers`, fetch, }); - assert([200, 405].includes(res.status), "200 or 405 depending on server handling"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect([200, 405].includes(res.status)).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1523,22 +1168,18 @@ const enableTool = true; }); throw new Error("Expected onerror due to @connect, but got onload"); } catch (e) { - assertEq(e.kind, "error", "onerror path taken"); - assert(e.res, "e.res exists"); - assertEq(e.res.status, 0, "status 0"); - assertEq(e.res.statusText, "", 'statusText ""'); - assertEq(e.res.finalUrl, undefined, "finalUrl undefined"); - assertEq(e.res.readyState, 4, "readyState DONE"); - assertEq(!e.res.response, true, "!response ok"); - assertEq(e.res.responseText, "", 'responseText ""'); - assertEq(e.res.responseXML, undefined, "responseXML undefined"); - assertEq(typeof (e.res.error || undefined), "string", "error set"); - assertEq( - `${e.res.error}`.includes(`Refused to connect to "https://example.org/": `), - true, - "Refused to connect to ..." - ); - assertEq(objectProps(e.res), "ok", "Object Props OK"); + expect(e.kind).toBe("error"); + expect(e.res).toBeTruthy(); + expect(e.res.status).toBe(0); + expect(e.res.statusText).toBe(""); + expect(e.res.finalUrl).toBe(undefined); + expect(e.res.readyState).toBe(4); + expect(!e.res.response).toBe(true); + expect(e.res.responseText).toBe(""); + expect(e.res.responseXML).toBe(undefined); + expect(typeof (e.res.error || undefined)).toBe("string"); + expect(`${e.res.error}`.includes(`Refused to connect to "https://example.org/": `)).toBe(true); + expect(objectProps(e.res)).toBe("ok"); } }, }, @@ -1553,22 +1194,18 @@ const enableTool = true; }); throw new Error("Expected error, got load"); } catch (e) { - assertEq(e.kind, "error", "onerror path taken"); - assert(e.res, "e.res exists"); - assertEq(e.res.status, 0, "status 0"); - assertEq(e.res.statusText, "", 'statusText ""'); - assertEq(e.res.finalUrl, undefined, "finalUrl undefined"); - assertEq(e.res.readyState, 4, "readyState DONE"); - assertEq(!e.res.response, true, "!response ok"); - assertEq(e.res.responseText, "", 'responseText ""'); - assertEq(e.res.responseXML, undefined, "responseXML undefined"); - assertEq(typeof (e.res.error || undefined), "string", "error set"); - assertEq( - `${e.res.error}`.includes(`Refused to connect to "http://domain-abcxyz.test/": `), - true, - "Refused to connect to ..." - ); - assertEq(objectProps(e.res), "ok", "Object Props OK"); + expect(e.kind).toBe("error"); + expect(e.res).toBeTruthy(); + expect(e.res.status).toBe(0); + expect(e.res.statusText).toBe(""); + expect(e.res.finalUrl).toBe(undefined); + expect(e.res.readyState).toBe(4); + expect(!e.res.response).toBe(true); + expect(e.res.responseText).toBe(""); + expect(e.res.responseXML).toBe(undefined); + expect(typeof (e.res.error || undefined)).toBe("string"); + expect(`${e.res.error}`.includes(`Refused to connect to "http://domain-abcxyz.test/": `)).toBe(true); + expect(objectProps(e.res)).toBe("ok"); } }, }, @@ -1583,13 +1220,13 @@ const enableTool = true; }); throw new Error("Expected error, got load"); } catch (e) { - assertEq(e.kind, "error", "onerror path taken"); - assert(e.res, "e.res exists"); - assertEq(!e.res.response, true, "!response ok"); - assertEq(e.res.responseXML, undefined, "responseXML undefined"); - assertEq(e.res.responseHeaders, "", 'responseHeaders ""'); - assertEq(e.res.readyState, 4, "readyState 4"); - assertEq(objectProps(e.res), "ok", "Object Props OK"); + expect(e.kind).toBe("error"); + expect(e.res).toBeTruthy(); + expect(!e.res.response).toBe(true); + expect(e.res.responseXML).toBe(undefined); + expect(e.res.responseHeaders).toBe(""); + expect(e.res.readyState).toBe(4); + expect(objectProps(e.res)).toBe("ok"); } }, }, @@ -1610,7 +1247,7 @@ const enableTool = true; ]); throw new Error("Expected abort, got load"); } catch (e) { - assertEq(e.kind, "abort", "abort path taken"); + expect(e.kind).toBe("abort"); } }, }, @@ -1625,11 +1262,11 @@ const enableTool = true; fetch, onprogress() {}, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"method": "GET"'), true, "responseText ok"); - assertEq(typeof res.response === "object" && res.response?.method === "GET", true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(`${res.responseText}`.includes('"method": "GET"')).toBe(true); + expect(typeof res.response === "object" && res.response?.method === "GET").toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1646,12 +1283,12 @@ const enableTool = true; readyStateList.push(resp.readyState); }, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"method": "GET"'), true, "responseText ok"); - assertEq(typeof res.response === "object" && res.response?.method === "GET", true, "response ok"); - assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); - assertDeepEq(readyStateList, fetch ? [2, 4] : [1, 2, 3, 4], "status 200"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect(`${res.responseText}`.includes('"method": "GET"')).toBe(true); + expect(typeof res.response === "object" && res.response?.method === "GET").toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(readyStateList).toEqual(fetch ? [2, 4] : [1, 2, 3, 4]); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1680,29 +1317,21 @@ const enableTool = true; }) ); if (!fetch) { - assertDeepEq( - resultList, - [ - "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", - "onreadystatechange 4.200;r=string;t=string;x=XMLDocument", - "onload 4.200;r=string;t=string;x=XMLDocument", - "onloadend 4.200;r=string;t=string;x=XMLDocument", - ], - "standard-type GMXhr OK" - ); + 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", + "onreadystatechange 4.200;r=string;t=string;x=XMLDocument", + "onload 4.200;r=string;t=string;x=XMLDocument", + "onloadend 4.200;r=string;t=string;x=XMLDocument", + ]); } else { - assertDeepEq( - resultList, - [ - "onreadystatechange 2.200;r=missing;t=missing;x=missing", - "onreadystatechange 4.200;r=string;t=string;x=XMLDocument", - "onload 4.200;r=string;t=string;x=XMLDocument", - "onloadend 4.200;r=string;t=string;x=XMLDocument", - ], - "fetch-type GMXhr OK" - ); + expect(resultList).toEqual([ + "onreadystatechange 2.200;r=missing;t=missing;x=missing", + "onreadystatechange 4.200;r=string;t=string;x=XMLDocument", + "onload 4.200;r=string;t=string;x=XMLDocument", + "onloadend 4.200;r=string;t=string;x=XMLDocument", + ]); } }, }, @@ -1739,34 +1368,26 @@ const enableTool = true; ); const resultList = [...resultSet]; if (!fetch) { - assertEq(progressCount >= 2, true, "progressCount ok"); - assertDeepEq( - resultList, - [ - "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), - "standard-type GMXhr OK" - ); + 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)); } else { - assertEq(progressCount >= 2, true, "progressCount ok"); - assertDeepEq( - resultList, - [ - "onreadystatechange 2.200;r=missing;t=missing;x=missing", - "onprogress 3.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", - ], - "fetch-type GMXhr OK" - ); + expect(progressCount >= 2).toBe(true); + expect(resultList).toEqual([ + "onreadystatechange 2.200;r=missing;t=missing;x=missing", + "onprogress 3.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", + ]); } }, }, @@ -1803,34 +1424,26 @@ const enableTool = true; ); const resultList = [...resultSet]; if (!fetch) { - assertEq(progressCount >= 2, true, "progressCount ok"); - assertDeepEq( - resultList, - [ - "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), - "standard-type GMXhr OK" - ); + 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)); } else { - assertEq(progressCount >= 2, true, "progressCount ok"); - assertDeepEq( - resultList, - [ - "onreadystatechange 2.200;r=missing;t=missing;x=missing", - "onprogress 3.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", - ], - "fetch-type GMXhr OK" - ); + expect(progressCount >= 2).toBe(true); + expect(resultList).toEqual([ + "onreadystatechange 2.200;r=missing;t=missing;x=missing", + "onprogress 3.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", + ]); } }, }, @@ -1865,14 +1478,10 @@ const enableTool = true; }) ); const headers = resultHeaders; - assertEq(headers.get("content-type"), "application/json; charset=utf-8", "content-type ok"); - assertEq( - headers.get("reporting-endpoints").replace(/context=[-+\w]+/, "context=eJzj4tD"), - 'default="/_/TranslateApiHttp/web-reports?context=eJzj4tD"', - "reporting-endpoints ok" - ); - assertEq(headers.get("cross-origin-opener-policy"), "same-origin", "cross-origin-opener-policy ok"); - assertEq(headers.get("content-encoding") !== "deflate", true, "content-encoding ok"); + 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("cross-origin-opener-policy")).toBe("same-origin"); + expect(headers.get("content-encoding") !== "deflate").toBe(true); }, }, { @@ -1908,38 +1517,30 @@ const enableTool = true; }) ); const headers = resultHeaders; - assertEq(headers.get("content-type"), "application/json; charset=utf-8", "content-type ok"); - assertEq( - headers.get("reporting-endpoints").replace(/context=[-+\w]+/, "context=eJzj4tD"), - 'default="/_/TranslateApiHttp/web-reports?context=eJzj4tD"', - "reporting-endpoints ok" - ); - assertEq(headers.get("cross-origin-opener-policy"), "same-origin", "cross-origin-opener-policy ok"); - assertEq( - headers.get("content-encoding") === "deflate" || headers.get("content-encoding") === null, - true, - "content-encoding ok" - ); + 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("cross-origin-opener-policy")).toBe("same-origin"); + expect(headers.get("content-encoding") === "deflate" || headers.get("content-encoding") === null).toBe(true); }, }, { name: "Response headers line endings", async run(fetch) { - const url = `${HB}/status/200`; + const url = `${HB}/get`; const { res } = await gmRequest({ method: "GET", url, fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(typeof res.responseHeaders, "string", "responseHeaders is string"); - assertEq(res.responseHeaders.trim() === res.responseHeaders, true, "no extra space"); + expect(res.status).toBe(200); + expect(typeof res.responseHeaders).toBe("string"); + expect(res.responseHeaders.trim() === res.responseHeaders).toBe(true); // Each line should end with \r\n const lines = res.responseHeaders.split("\r\n"); for (let i = 0; i < lines.length - 1; i++) { - assert(lines[i].length > 0, `header line ${i} present`); + expect(lines[i].length > 0).toBeTruthy(); } - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(objectProps(res)).toBe("ok"); }, }, ]; @@ -1951,18 +1552,6 @@ const enableTool = true; }), ]; - // ---------- Assertion utils ---------- - function assert(condition, msg) { - if (!condition) throw new Error(msg || "assertion failed"); - } - function assertEq(a, b, msg) { - if (a !== b) throw new Error(msg ? `${msg}: expected ${b}, got ${a}` : `expected ${b}, got ${a}`); - } - function assertDeepEq(a, b, msg) { - const aj = JSON.stringify(a); - const bj = JSON.stringify(b); - if (aj !== bj) throw new Error(msg ? `${msg}: expected ${bj}, got ${aj}` : `deep equal failed`); - } function getHeader(headersStr, key) { const lines = (headersStr || "").split(/\r?\n/); const line = lines.find((l) => l.toLowerCase().startsWith(key.toLowerCase() + ":")); @@ -1995,49 +1584,13 @@ const enableTool = true; return "ok"; } - // ---------- Runner ---------- - async function runAll() { - // reset counts - state.pass = state.fail = state.skip = 0; - setCounts(0, 0, 0); - const names = tests.map((t) => t.name); - setQueue(names.slice()); - logLine(`Starting GM_xmlhttpRequest test suite — ${new Date().toLocaleString()}`); - - for (let i = 0; i < tests.length; i++) { - const t = tests[i]; - const tName = `${t.useFetch ? "[fetch]" : "[xhr]"} ${t.name}`; - const title = `• ${tName}`; - const t0 = performance.now(); - setStatus(`running (${i + 1}/${tests.length}): ${tName}`); - try { - logLine(`▶️ ${escapeHtml(tName)} (queued: ${tests.length - i - 1} remaining)`); - await t.run(t.useFetch ? true : false); - pass(`${title} (${fmtMs(performance.now() - t0)})`); - } catch (e) { - console.error(e); - const extra = e && e.stack ? prettyStack(e, { maxLines: 4 }) : null; - fail(`${title} (${fmtMs(performance.now() - t0)})`, [e?.message, extra].filter(Boolean).join("\n")); - } finally { - // update pending list - setQueue(names.slice(i + 1)); - } + // 138 个用例都会真的发请求,还会设置/删除 cookie,一轮要跑几十秒, + // 所以不随页面加载自动开跑,由面板的运行按钮触发。 + describe("GM_xmlhttpRequest", { auto: false }, () => { + for (const t of tests) { + it(`${t.useFetch ? "[fetch]" : "[xhr]"} ${t.name}`, () => t.run(t.useFetch ? true : false)); } + }); - setStatus("done"); - logLine(`Done. Summary — ✅ ${state.pass} ❌ ${state.fail} ⏳ ${state.skip}`); - } - - function fmtMs(ms) { - return ms < 1000 ? `${ms | 0}ms` : `${(ms / 1000).toFixed(2)}s`; - } - - // Auto-run once after a short delay to let the page settle - setTimeout(() => { - // Only auto-run if not already run in this page session - if (!window.__gmxhr_test_autorun__) { - window.__gmxhr_test_autorun__ = true; - runAll(); - } - }, 600); + await run(); })(); diff --git a/example/tests/inject_content_test.js b/example/tests/inject_content_test.js index e34dd5805..4d68fa9f7 100644 --- a/example/tests/inject_content_test.js +++ b/example/tests/inject_content_test.js @@ -14,158 +14,102 @@ // @grant GM_getValue // @grant GM_deleteValue // @grant GM_listValues +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js // @run-at document-start // ==/UserScript== (async function () { "use strict"; - console.log("%c=== Content环境 GM API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); + const { describe, it, expect, run } = SCTest.create({ name: "Inject-into content 环境测试" }); - let testResults = { - passed: 0, - failed: 0, - total: 0, - }; - - // 测试辅助函数 - async function test(name, fn) { - testResults.total++; - try { - await fn(); - testResults.passed++; - console.log(`%c✓ ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%c✗ ${name}`, "color: red;", error); - return false; - } - } - - // assert(expected, actual, message) - 比较两个值是否相等 - function assert(expected, actual, message) { - if (expected !== actual) { - const valueInfo = `期望 ${JSON.stringify(expected)}, 实际 ${JSON.stringify(actual)}`; - const error = message ? `${message} - ${valueInfo}` : `断言失败: ${valueInfo}`; - throw new Error(error); - } - } - - // assertTrue(condition, message) - 断言条件为真 - function assertTrue(condition, message) { - if (!condition) { - throw new Error(message || "断言失败: 期望条件为真"); - } - } - - // ============ CSP绕过测试 ============ - console.log("\n%c--- CSP绕过测试 ---", "color: orange; font-weight: bold;"); - - await test("CSP绕过 - 内联脚本", () => { - const script = document.createElement("script"); - script.textContent = 'console.log("Content环境绕过CSP测试");'; - document.head.appendChild(script); - assertTrue(script.parentNode === document.head, "脚本应该成功插入到head中"); + 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(); + }); }); - // ============ GM_addElement/GM_addStyle 测试 ============ - console.log("\n%c--- DOM操作 API 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_addElement", () => { - const element = GM_addElement("div", { - textContent: "GM_addElement测试元素", - style: "display:none;", - id: "gm-test-element", + 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"); }); - assertTrue(element !== null && element !== undefined, "GM_addElement应该返回元素"); - assert("gm-test-element", element.id, "元素ID应该正确"); - assert("DIV", element.tagName, "元素标签应该是DIV"); - console.log("返回元素:", element); - }); - await test("GM_addStyle", () => { - const styleElement = GM_addStyle(` + it("GM_addStyle", () => { + const styleElement = GM_addStyle(` .gm-style-test { color: #10b981 !important; } `); - assertTrue(styleElement !== null && styleElement !== undefined, "GM_addStyle应该返回样式元素"); - assertTrue(styleElement.tagName === "STYLE" || styleElement.sheet, "应该返回STYLE元素或样式对象"); - console.log("返回样式元素:", styleElement); + expect(styleElement !== null && styleElement !== undefined).toBeTruthy(); + expect(styleElement.tagName === "STYLE" || styleElement.sheet).toBeTruthy(); + }); }); - // ============ GM_log 测试 ============ - console.log("\n%c--- GM_log 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_log", () => { - GM_log("测试日志输出", "info", { type: "test", value: 123 }); - // GM_log本身不返回值,只要不抛出异常就算成功 - assertTrue(true, "GM_log应该能正常输出"); + describe("GM_log 测试", () => { + it("GM_log", () => { + GM_log("测试日志输出", "info", { type: "test", value: 123 }); + // GM_log本身不返回值,只要不抛出异常就算成功 + expect(true).toBeTruthy(); + }); }); - // ============ GM_info 测试 ============ - console.log("\n%c--- GM_info 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_info", () => { - assertTrue(typeof GM_info === "object", "GM_info应该是对象"); - assertTrue(!!GM_info.script, "GM_info.script应该存在"); - assertTrue(!!GM_info.script.name, "GM_info.script.name应该存在"); - console.log("脚本信息:", GM_info.script.name); + describe("GM_info 测试", () => { + it("GM_info", () => { + expect(typeof GM_info === "object").toBeTruthy(); + expect(!!GM_info.script).toBeTruthy(); + expect(!!GM_info.script.name).toBeTruthy(); + }); }); - // ============ GM 存储 API 测试 ============ - console.log("\n%c--- GM 存储 API 测试 ---", "color: orange; font-weight: bold;"); - - await test("GM_setValue - 字符串", async () => { - await GM.setValue("test_key", "content环境测试值"); - const value = GM_getValue("test_key"); - assert("content环境测试值", value, "应该正确保存和读取字符串"); - }); + describe("GM 存储 API 测试", () => { + it("GM_setValue - 字符串", async () => { + await GM.setValue("test_key", "content环境测试值"); + const value = GM_getValue("test_key"); + expect(value).toBe("content环境测试值"); + }); - await test("GM_setValue - 数字", () => { - GM_setValue("test_number", 12345); - const value = GM_getValue("test_number"); - assert(12345, value, "应该正确保存和读取数字"); - }); + it("GM_setValue - 数字", () => { + GM_setValue("test_number", 12345); + const value = GM_getValue("test_number"); + expect(value).toBe(12345); + }); - await test("GM_setValue - 对象", () => { - const obj = { name: "ScriptCat", type: "content" }; - GM_setValue("test_object", obj); - const value = GM_getValue("test_object", {}); - assert("ScriptCat", value.name, "对象的name属性应该正确"); - assert("content", value.type, "对象的type属性应该正确"); - }); + 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"); + }); - await test("GM_getValue - 默认值", () => { - const value = GM_getValue("non_existent_key", "默认值"); - assert("默认值", value, "不存在的键应该返回默认值"); - }); + it("GM_getValue - 默认值", () => { + const value = GM_getValue("non_existent_key", "默认值"); + expect(value).toBe("默认值"); + }); - await test("GM_listValues", () => { - const keys = GM_listValues(); - assertTrue(Array.isArray(keys), "GM_listValues应该返回数组"); - assertTrue(keys.length >= 3, "应该至少有3个存储键"); - console.log("存储的键:", keys); - }); + it("GM_listValues", () => { + const keys = GM_listValues(); + expect(Array.isArray(keys)).toBeTruthy(); + expect(keys.length >= 3).toBeTruthy(); + }); - await test("GM_deleteValue", () => { - GM_setValue("test_delete", "to_be_deleted"); - assert("to_be_deleted", GM_getValue("test_delete"), "值应该存在"); - GM_deleteValue("test_delete"); - assert(null, GM_getValue("test_delete", 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); + }); }); - // ============ 输出测试结果 ============ - console.log("\n%c=== 测试完成 ===", "color: blue; font-size: 16px; font-weight: bold;"); - console.log( - `%c总计: ${testResults.total} | 通过: ${testResults.passed} | 失败: ${testResults.failed}`, - testResults.failed === 0 ? "color: green; font-weight: bold;" : "color: red; font-weight: bold;" - ); - - if (testResults.failed === 0) { - console.log("%c🎉 所有测试通过!", "color: green; font-size: 14px; font-weight: bold;"); - } else { - console.log("%c⚠️ 部分测试失败,请检查上面的错误信息", "color: red; font-size: 14px; font-weight: bold;"); - } + await run(); })(); diff --git a/example/tests/lib/README.md b/example/tests/lib/README.md new file mode 100644 index 000000000..d5fe5e962 --- /dev/null +++ b/example/tests/lib/README.md @@ -0,0 +1,85 @@ +# sctest — example/tests 共用测试框架 + +零依赖、零构建的单文件测试框架,供 `example/tests/` 下的用户脚本共用。 + +## 引入 + +```js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@762f83e9c1091ab4ebbb605f4efc4709b36f6476/example/tests/lib/sctest.js +``` + +e2e 运行时该 URL 会被自动重写到本地 mock server(见 `e2e/gm-api.spec.ts` 的 `patchRequireCode`), +因此 CI 无需外网,且测的永远是工作区版本。 + +## 用法 + +```js +const { describe, it, itManual, expect, 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"); + }); +}); + +// 有副作用的组:默认不自动跑,点面板「运行全部」才启动 +describe("GM_download", { auto: false, params: { prefix: "sc-test-" } }, () => { + it("下载文件", async () => { /* ... */ }); +}); + +// 需要人工操作的用例 +describe("GM_registerMenuCommand", () => { + itManual("点击「测试命令 A」后弹出提示", { hint: "打开扩展图标 → 脚本菜单 → 点击「测试命令 A」" }); +}); + +run(); +``` + +## 断言 + +统一 `expect(actual).matcher(expected)`,**实际值在前**。 + +| matcher | 说明 | +|---|---| +| `toBe(expected)` | `!==` 严格比较 | +| `toEqual(expected)` | 结构化递归深比较(键顺序不敏感;区分 NaN/null、undefined 键) | +| `toBeTruthy()` | 真值 | +| `toBeTypeOf(type)` | `typeof` 比较 | +| `toMatch(pattern)` | 正则或子串 | +| `toThrow(pattern?)` | 被测目标须为函数;可选校验异常消息 | + +## 主动跳过 + +条件不满足时用 `SCTest.skip(reason)` 从用例体内退出,记为跳过而非失败,原因会出现在 +控制台、面板与 `GM_log` 里: + +```js +it("需要浏览器原生下载", async () => { + const v = await awaitVerdict(); + if (v.verdict === "skip") SCTest.skip(`${v.reason} (未落盘)`); + expect(v.ok).toBeTruthy(); +}); +``` + +不要靠约定错误消息前缀来表达跳过 —— 消息碰巧同名的真实错误会被一并吞掉。 + +## 展示通道 + +三个 reporter 可叠加,由 `SCTest.create({ reporter })` 控制,默认 `"auto"`: + +| reporter | auto 模式下的启用条件 | 说明 | +|---|---|---| +| Console | **恒定开启** | 全量输出,末尾三行汇总是 e2e 的解析契约,勿改格式 | +| Panel | 运行上下文为 `page` | Shadow DOM 浮层面板,宿主 id `sctest-panel-host` | +| Log | 运行上下文为 `background` / `crontab` | `GM_log` + 结构化 label,落到「运行日志」页 | + +运行上下文由 `GM_info.scriptMetaStr` 里的 `@background` / `@crontab` 判定 —— 后台脚本跑在 offscreen +文档里,`document` 是存在的,所以不能用 `typeof document === "undefined"` 判断。 + +用 `GM_log` 通道时脚本必须 `@grant GM_log`,否则日志会静默丢弃(Console 通道不受影响)。 diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js new file mode 100644 index 000000000..2a78f404f --- /dev/null +++ b/example/tests/lib/sctest.js @@ -0,0 +1,1184 @@ +/** + * sctest — ScriptCat example/tests 共用测试框架 + * 零依赖、零构建,供用户脚本 @require 使用。 + */ +(function (global) { + "use strict"; + + var STATUS = { PASS: "pass", FAIL: "fail", SKIP: "skip", MANUAL: "manual" }; + + // GM_info.script 不含 background/crontab 字段(见 src/app/service/content/gm_api/gm_info.ts), + // 只能从 metadata 原文判断运行上下文。 + function detectContext(metaStr) { + var meta = metaStr || ""; + if (/^\/\/\s*@crontab\s+\S/m.test(meta)) return "crontab"; + if (/^\/\/\s*@background\s*$/m.test(meta)) return "background"; + if (typeof document === "undefined") return "background"; + return "page"; + } + + function currentMetaStr() { + try { + if (typeof GM_info !== "undefined" && GM_info) return GM_info.scriptMetaStr || ""; + } catch (e) { + /* GM_info 未授权时忽略 */ + } + return ""; + } + + function stringify(value) { + if (typeof value === "function") return "[Function " + (value.name || "anonymous") + "]"; + try { + var out = JSON.stringify(value); + return out === undefined ? String(value) : out; + } catch (e) { + return String(value); + } + } + + // 用例体内主动跳过的信号。用独立类型而不是约定 message 前缀,是因为前缀嗅探会把 + // 消息碰巧同名的真实错误一并吞成跳过。 + function SkipSignal(reason) { + this.reason = reason || ""; + } + + function AssertionError(message, expected, actual) { + var err = new Error(message); + err.name = "AssertionError"; + err.expected = expected; + err.actual = actual; + return err; + } + + // 结构化深比较。刻意不同于部分用户脚本里基于 JSON.stringify 的 assertDeepEq: + // NaN 与自身相等、值为 undefined 的键与缺失的键不同、对象键顺序不影响比较结果。 + // 通过 seen 记录比较路径中的 (a,b) 组合来避免循环引用导致的栈溢出, + // 但不做真正的循环感知等价判断——只是让循环结构比较能有限终止,而非精确语义。 + // 已知限制: Date/RegExp/Map/Set 按普通对象比较(自有可枚举键为空), 因此两个不同的 Date + // 会被判为相等。当前所有迁移用例的 toEqual 只比较纯对象/数组/基元, 未触及这些类型。 + function deepEqual(a, b, seen) { + if (Object.is(a, b)) return true; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; + + var aIsArray = Array.isArray(a); + var bIsArray = Array.isArray(b); + if (aIsArray !== bIsArray) return false; + + seen = seen || []; + for (var s = 0; s < seen.length; s++) { + if (seen[s][0] === a && seen[s][1] === b) return true; + } + seen.push([a, b]); + + if (aIsArray) { + if (a.length !== b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i], seen)) return false; + } + return true; + } + + var aKeys = Object.keys(a); + var bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + for (var k = 0; k < aKeys.length; k++) { + var key = aKeys[k]; + if (!Object.prototype.hasOwnProperty.call(b, key)) return false; + if (!deepEqual(a[key], b[key], seen)) return false; + } + return true; + } + + function makeExpect() { + return function expect(actual) { + return { + toBe: function (expected) { + if (actual !== expected) { + throw AssertionError( + "期望 " + stringify(expected) + ",实际 " + stringify(actual), + stringify(expected), + stringify(actual) + ); + } + }, + toEqual: function (expected) { + if (!deepEqual(actual, expected)) { + var b = stringify(expected); + var a = stringify(actual); + throw AssertionError("期望 " + b + ",实际 " + a, b, a); + } + }, + toBeTruthy: function () { + if (!actual) throw AssertionError("期望为真值,实际 " + stringify(actual), "truthy", stringify(actual)); + }, + toBeTypeOf: function (expected) { + var t = typeof actual; + if (t !== expected) throw AssertionError("期望类型 " + expected + ",实际 " + t, expected, t); + }, + toMatch: function (pattern) { + var text = String(actual); + var ok = pattern instanceof RegExp ? pattern.test(text) : text.indexOf(String(pattern)) !== -1; + if (!ok) { + throw AssertionError("期望匹配 " + String(pattern) + ",实际 " + stringify(text), String(pattern), text); + } + }, + toThrow: function (pattern) { + if (typeof actual !== "function") { + throw AssertionError("toThrow 的被测目标必须是函数,实际 " + typeof actual, "function", typeof actual); + } + var thrown = null; + try { + actual(); + } catch (e) { + thrown = e; + } + if (!thrown) throw AssertionError("期望抛出异常,实际未抛出", "throw", "no throw"); + if (pattern) { + var msg = String((thrown && thrown.message) || thrown); + var ok = pattern instanceof RegExp ? pattern.test(msg) : msg.indexOf(String(pattern)) !== -1; + if (!ok) { + throw AssertionError("期望异常匹配 " + String(pattern) + ",实际 " + msg, String(pattern), msg); + } + } + }, + }; + }; + } + + function now() { + return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); + } + + function create(options) { + var opts = options || {}; + var runName = opts.name || "未命名测试"; + var context = opts.context || detectContext(currentMetaStr()); + var suites = []; + var currentSuite = null; + + function describe(name, optsOrFn, maybeFn) { + var suiteOpts = typeof optsOrFn === "function" ? {} : optsOrFn || {}; + var fn = typeof optsOrFn === "function" ? optsOrFn : maybeFn; + var suite = { + name: name, + auto: suiteOpts.auto !== false, + params: suiteOpts.params || {}, + cases: [], + }; + suites.push(suite); + currentSuite = suite; + try { + fn(); + } finally { + currentSuite = null; + } + } + + function pushCase(name, fn, kind, hint) { + if (!currentSuite) throw new Error("it/itManual 必须写在 describe 内部:" + name); + currentSuite.cases.push({ + name: name, + suite: currentSuite.name, + fn: fn, + kind: kind, + hint: hint || "", + status: null, + durationMs: 0, + error: null, + expected: null, + actual: null, + }); + } + + function it(name, fn) { + pushCase(name, fn, "auto", ""); + } + + function itManual(name, manualOpts) { + pushCase(name, null, "manual", (manualOpts || {}).hint); + } + + function toResult(c) { + return { + name: c.name, + suite: c.suite, + status: c.status, + durationMs: c.durationMs, + error: c.error, + expected: c.expected, + actual: c.actual, + hint: c.hint, + }; + } + + async function runCase(c, reporters) { + if (c.kind === "manual") { + c.status = STATUS.MANUAL; + } else { + var started = now(); + try { + await c.fn(); + c.status = STATUS.PASS; + } catch (e) { + if (e instanceof SkipSignal) { + c.status = STATUS.SKIP; + c.error = e.reason; + } else { + c.status = STATUS.FAIL; + c.error = String((e && e.message) || e); + c.expected = (e && e.expected) || null; + c.actual = (e && e.actual) || null; + } + } + c.durationMs = Math.round(now() - started); + } + var result = toResult(c); + reporters.forEach(function (r) { + if (r.onCase) r.onCase(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 { + 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) { + var startedAt = now(); + for (var i = 0; i < suites.length; i++) { + var suite = suites[i]; + if (!includeAutoSuites && suite.auto) continue; + if (onlySuiteName && suite.name !== onlySuiteName) continue; + for (var j = 0; j < suite.cases.length; j++) { + var c = suite.cases[j]; + if (c.kind === "manual") continue; + c.status = null; + c.error = null; + 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); + }); + return summary; + } + + async function run() { + var runInfo = { name: runName, context: context, suites: suites, onRunManual: null }; + var reporters = global.SCTest.__buildReporters(opts, context, runInfo); + runInfo.onRunManual = function (suiteName) { + return rerunSuites(reporters, suiteName, false); + }; + runInfo.onRerun = function () { + return rerunSuites(reporters, null, true); + }; + var startedAt = now(); + reporters.forEach(function (r) { + if (r.onStart) r.onStart(runInfo); + }); + + for (var i = 0; i < suites.length; i++) { + var suite = suites[i]; + for (var j = 0; j < suite.cases.length; j++) { + 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); + }); + continue; + } + await runCase(c, reporters); + } + } + + var summary = buildSummary(startedAt); + reporters.forEach(function (r) { + if (r.onEnd) r.onEnd(summary); + }); + return summary; + } + + return { describe: describe, it: it, itManual: itManual, expect: makeExpect(), run: run }; + } + + // ---------- ConsoleReporter ---------- + function createConsoleReporter() { + var lastSuite = null; + return { + onStart: function (info) { + console.log("%c=== " + info.name + " 测试开始 (" + info.context + ") ===", "color: blue; font-weight: bold;"); + }, + onCase: function (c) { + if (c.suite !== lastSuite) { + 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;"); + } + }, + 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)"); + }, + }; + } + + // ---------- 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}", + "[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-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-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);", + "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}", + ".sc-spacer{flex:1}", + ".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-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-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-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-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 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-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);", + "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-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)}", + ".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}}", + ].join(""); + + // Constructable stylesheet 通过 CSSOM 安装到 Shadow Root,不属于页面的 inline