From bf545619ab1816d43b35ffaec65a7d1c91039a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 13:21:31 +0800 Subject: [PATCH 01/28] =?UTF-8?q?=E2=9C=85=20=E6=96=B0=E5=A2=9E=20example/?= =?UTF-8?q?tests=20=E5=85=B1=E7=94=A8=E6=B5=8B=E8=AF=95=E6=A1=86=E6=9E=B6?= =?UTF-8?q?=E5=86=85=E6=A0=B8=E4=B8=8E=20Console=20reporter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/tests/lib/sctest.js | 297 +++++++++++++++++++++++++++++++ example/tests/lib/sctest.test.js | 151 ++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 example/tests/lib/sctest.js create mode 100644 example/tests/lib/sctest.test.js diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js new file mode 100644 index 000000000..ae0150fbc --- /dev/null +++ b/example/tests/lib/sctest.js @@ -0,0 +1,297 @@ +/** + * 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); + } + } + + function AssertionError(message, expected, actual) { + var err = new Error(message); + err.name = "AssertionError"; + err.expected = expected; + err.actual = actual; + return err; + } + + 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) { + var a = stringify(actual); + var b = stringify(expected); + if (a !== b) 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) { + 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 run() { + var reporters = global.SCTest.__buildReporters(opts, context, { suites: suites, name: runName }); + var startedAt = now(); + reporters.forEach(function (r) { + if (r.onStart) r.onStart({ name: runName, context: context, suites: suites }); + }); + + 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) { + console.log("%c○ " + c.name + " (待人工确认)", "color: #999;"); + } else { + console.log("%c○ " + c.name + " (跳过)", "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)"); + }, + }; + } + + var api = { + create: create, + __detectContext: detectContext, + __buildReporters: function () { + return [createConsoleReporter()]; + }, + __createConsoleReporter: createConsoleReporter, + STATUS: STATUS, + }; + + global.SCTest = api; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js new file mode 100644 index 000000000..e0e08824a --- /dev/null +++ b/example/tests/lib/sctest.test.js @@ -0,0 +1,151 @@ +import { beforeEach, describe as vdescribe, expect as vexpect, it as vit } from "vitest"; + +async function loadSCTest() { + delete globalThis.SCTest; + await import("./sctest.js?t=" + Math.random()); + return globalThis.SCTest; +} + +vdescribe("sctest 框架内核", () => { + let SCTest; + + beforeEach(async () => { + SCTest = await loadSCTest(); + }); + + vdescribe("运行上下文检测", () => { + vit("识别 @crontab 为 crontab", () => { + const meta = "// ==UserScript==\n// @name x\n// @crontab */15 * * * *\n// ==/UserScript=="; + vexpect(SCTest.__detectContext(meta)).toBe("crontab"); + }); + + vit("识别 @background 为 background", () => { + const meta = "// ==UserScript==\n// @name x\n// @background\n// ==/UserScript=="; + vexpect(SCTest.__detectContext(meta)).toBe("background"); + }); + + vit("普通页面脚本为 page", () => { + const meta = "// ==UserScript==\n// @name x\n// @match *://*/*\n// ==/UserScript=="; + vexpect(SCTest.__detectContext(meta)).toBe("page"); + }); + + vit("不把 @backgroundcolor 之类前缀误判为 @background", () => { + const meta = "// ==UserScript==\n// @backgroundcolor red\n// ==/UserScript=="; + vexpect(SCTest.__detectContext(meta)).toBe("page"); + }); + }); + + vdescribe("expect 断言", () => { + vit("toBe 相等时不抛异常", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e(1).toBe(1)).not.toThrow(); + }); + + vit("toBe 不等时抛出含期望与实际的错误", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e("b").toBe("a")).toThrowError(/期望 "a".*实际 "b"/); + }); + + vit("toEqual 做深比较", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e({ a: [1, 2] }).toEqual({ a: [1, 2] })).not.toThrow(); + vexpect(() => e({ a: [1, 2] }).toEqual({ a: [2, 1] })).toThrow(); + }); + + vit("toBeTypeOf 校验 typeof", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e("s").toBeTypeOf("string")).not.toThrow(); + vexpect(() => e("s").toBeTypeOf("number")).toThrow(); + }); + + vit("toThrow 要求被测目标是函数并且确实抛异常", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e(() => { throw new Error("boom"); }).toThrow()).not.toThrow(); + vexpect(() => e(() => {}).toThrow()).toThrow(); + vexpect(() => e(() => { throw new Error("boom"); }).toThrow(/boom/)).not.toThrow(); + }); + + vit("toMatch 支持正则与子串", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e("hello world").toMatch(/world/)).not.toThrow(); + vexpect(() => e("hello world").toMatch("hello")).not.toThrow(); + vexpect(() => e("hello world").toMatch("nope")).toThrow(); + }); + }); + + vdescribe("运行核心", () => { + vit("统计通过/失败/跳过并按 suite 分组", async () => { + const { describe: d, it: i, itManual: im, expect: e, run } = SCTest.create({ + name: "demo", + reporter: "console", + }); + d("组一", () => { + i("通过用例", () => e(1).toBe(1)); + i("失败用例", () => e(1).toBe(2)); + }); + d("组二", () => { + i("异步通过", async () => { + await Promise.resolve(); + e("x").toBe("x"); + }); + im("人工用例", { hint: "点一下" }); + }); + + const summary = await run(); + + vexpect(summary.total).toBe(4); + vexpect(summary.passed).toBe(2); + vexpect(summary.failed).toBe(1); + vexpect(summary.skipped).toBe(1); + vexpect(summary.suites.map((s) => s.name)).toEqual(["组一", "组二"]); + vexpect(summary.suites[0].cases[1].status).toBe("fail"); + vexpect(summary.suites[0].cases[1].error).toMatch(/期望 2/); + vexpect(summary.suites[1].cases[1].status).toBe("manual"); + }); + + vit("一个用例抛异常不影响后续用例执行", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "console" }); + d("组", () => { + i("炸", () => { throw new Error("boom"); }); + i("仍然跑", () => e(1).toBe(1)); + }); + const summary = await run(); + vexpect(summary.passed).toBe(1); + vexpect(summary.failed).toBe(1); + }); + + vit("auto:false 的 suite 默认不执行,记为 skip", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "console" }); + d("手动组", { auto: false }, () => { + i("不该自动跑", () => e(1).toBe(2)); + }); + const summary = await run(); + vexpect(summary.skipped).toBe(1); + vexpect(summary.failed).toBe(0); + }); + }); + + vdescribe("ConsoleReporter 契约", () => { + vit("输出三行汇总,格式与 e2e 正则一致", async () => { + const lines = []; + const orig = console.log; + console.log = (...args) => lines.push(args.map(String).join(" ")); + try { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "console" }); + d("组", () => { + i("a", () => e(1).toBe(1)); + i("b", () => e(1).toBe(2)); + }); + await run(); + } finally { + console.log = orig; + } + const text = lines.join("\n"); + vexpect(text).toMatch(/总测试数: 2/); + vexpect(text).toMatch(/通过: 1/); + vexpect(text).toMatch(/失败: 1/); + vexpect(/(通过|Passed)[::]\s*(\d+)/.exec(text)[2]).toBe("1"); + vexpect(/(失败|Failed)[::]\s*(\d+)/.exec(text)[2]).toBe("1"); + }); + }); +}); From c589d60d5948d41d5b19cdc4efbd433cfc6580fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 13:38:04 +0800 Subject: [PATCH 02/28] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20sctes?= =?UTF-8?q?t=20toEqual=20=E7=9A=84=20JSON=20=E5=BA=8F=E5=88=97=E5=8C=96?= =?UTF-8?q?=E8=AF=AF=E5=88=A4=E5=B9=B6=E8=A1=A5=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toEqual 原先用 stringify(actual) !== stringify(expected) 做深比较,导致 NaN 与 null 被误判相等、显式 undefined 键与缺失键被误判相等、对象键顺序 影响比较结果。改为手写的递归结构比较(Object.is 语义 + hasOwnProperty 探测键存在性 + 数组/对象类型互斥 + 循环引用防护),并补齐 toBeTruthy 的 真值/假值用例。 ConsoleReporter 的 MANUAL 分支此前丢弃了 c.hint,console-only 场景下 用户看不到人工确认需要做什么,现追加 hint 到既有 (待人工确认) 文案后。 --- example/tests/lib/sctest.js | 48 ++++++++++++-- example/tests/lib/sctest.test.js | 107 +++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js index ae0150fbc..d3e102178 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -44,6 +44,43 @@ return err; } + // 结构化深比较。刻意不同于部分用户脚本里基于 JSON.stringify 的 assertDeepEq: + // NaN 与自身相等、值为 undefined 的键与缺失的键不同、对象键顺序不影响比较结果。 + // 通过 seen 记录比较路径中的 (a,b) 组合来避免循环引用导致的栈溢出, + // 但不做真正的循环感知等价判断——只是让循环结构比较能有限终止,而非精确语义。 + 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 { @@ -57,9 +94,11 @@ } }, toEqual: function (expected) { - var a = stringify(actual); - var b = stringify(expected); - if (a !== b) throw AssertionError("期望 " + b + ",实际 " + a, b, a); + 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)); @@ -268,7 +307,8 @@ } else if (c.status === STATUS.FAIL) { console.error("%c✗ " + c.name, "color: red;", c.error); } else if (c.status === STATUS.MANUAL) { - console.log("%c○ " + c.name + " (待人工确认)", "color: #999;"); + var hintSuffix = c.hint ? ":" + c.hint : ""; + console.log("%c○ " + c.name + " (待人工确认" + hintSuffix + ")", "color: #999;"); } else { console.log("%c○ " + c.name + " (跳过)", "color: #999;"); } diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index e0e08824a..d81d59362 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -52,6 +52,79 @@ vdescribe("sctest 框架内核", () => { vexpect(() => e({ a: [1, 2] }).toEqual({ a: [2, 1] })).toThrow(); }); + vit("toEqual 对象键顺序不影响比较结果", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e({ a: 1, b: 2 }).toEqual({ b: 2, a: 1 })).not.toThrow(); + }); + + vit("toEqual 用 Object.is 语义,NaN 不等于 null", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e(NaN).toEqual(null)).toThrow(); + }); + + vit("toEqual 用 Object.is 语义,NaN 等于 NaN", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e(NaN).toEqual(NaN)).not.toThrow(); + }); + + vit("toEqual 显式 undefined 值的键与缺失键不同", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e({ a: undefined }).toEqual({})).toThrow(); + }); + + vit("toEqual 数组按元素与长度比较,且数组不等于普通对象", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e([1, 2]).toEqual([1, 2])).not.toThrow(); + vexpect(() => e([1, 2]).toEqual([1, 2, 3])).toThrow(); + vexpect(() => e([1, 2]).toEqual({ 0: 1, 1: 2 })).toThrow(); + }); + + vit("toEqual 正确处理 null 与对象的区分", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e(null).toEqual({})).toThrow(); + vexpect(() => e({}).toEqual(null)).toThrow(); + vexpect(() => e(null).toEqual(null)).not.toThrow(); + }); + + vit("toEqual 面对循环引用不会栈溢出", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + const a = { name: "x" }; + a.self = a; + const b = { name: "x" }; + b.self = b; + vexpect(() => e(a).toEqual(b)).not.toThrow(); + }); + + vit("toEqual 失败时抛出的错误带有 expected/actual 字段", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + let caught; + try { + e({ a: 1 }).toEqual({ a: 2 }); + } catch (err) { + caught = err; + } + vexpect(caught).toBeTruthy(); + vexpect(caught.name).toBe("AssertionError"); + vexpect(typeof caught.message).toBe("string"); + vexpect(caught.expected).toBe('{"a":2}'); + vexpect(caught.actual).toBe('{"a":1}'); + }); + + vit("toBeTruthy 假值抛出异常", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e(0).toBeTruthy()).toThrow(); + vexpect(() => e("").toBeTruthy()).toThrow(); + vexpect(() => e(null).toBeTruthy()).toThrow(); + vexpect(() => e(undefined).toBeTruthy()).toThrow(); + }); + + vit("toBeTruthy 真值不抛出异常", () => { + const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); + vexpect(() => e(1).toBeTruthy()).not.toThrow(); + vexpect(() => e("x").toBeTruthy()).not.toThrow(); + vexpect(() => e({}).toBeTruthy()).not.toThrow(); + }); + vit("toBeTypeOf 校验 typeof", () => { const { expect: e } = SCTest.create({ name: "t", reporter: "console" }); vexpect(() => e("s").toBeTypeOf("string")).not.toThrow(); @@ -147,5 +220,39 @@ vdescribe("sctest 框架内核", () => { vexpect(/(通过|Passed)[::]\s*(\d+)/.exec(text)[2]).toBe("1"); vexpect(/(失败|Failed)[::]\s*(\d+)/.exec(text)[2]).toBe("1"); }); + + vit("人工用例携带 hint 时,onCase 输出保留提示内容", async () => { + const lines = []; + const orig = console.log; + console.log = (...args) => lines.push(args.map(String).join(" ")); + try { + const { describe: d, itManual: im, run } = SCTest.create({ name: "demo", reporter: "console" }); + d("组", () => { + im("需要人工点击", { hint: "点一下确认按钮" }); + }); + await run(); + } finally { + console.log = orig; + } + const text = lines.join("\n"); + vexpect(text).toMatch(/○ 需要人工点击 \(待人工确认:点一下确认按钮\)/); + }); + + vit("人工用例没有 hint 时,沿用原有 (待人工确认) 措辞", async () => { + const lines = []; + const orig = console.log; + console.log = (...args) => lines.push(args.map(String).join(" ")); + try { + const { describe: d, itManual: im, run } = SCTest.create({ name: "demo", reporter: "console" }); + d("组", () => { + im("无提示的人工用例"); + }); + await run(); + } finally { + console.log = orig; + } + const text = lines.join("\n"); + vexpect(text).toMatch(/○ 无提示的人工用例 \(待人工确认\)/); + }); }); }); From 2a6ee464e3a1b093a0873c6dc1b46336b9ccb88a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 13:51:30 +0800 Subject: [PATCH 03/28] =?UTF-8?q?=E2=9C=85=20=E6=B5=8B=E8=AF=95=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E6=96=B0=E5=A2=9E=20Shadow=20DOM=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=20reporter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/tests/lib/sctest.js | 298 ++++++++++++++++++++++++++++++- example/tests/lib/sctest.test.js | 66 +++++++ 2 files changed, 361 insertions(+), 3 deletions(-) diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js index d3e102178..b6724bd45 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -323,13 +323,305 @@ }; } + // ---------- 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}", + ".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-title{font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}", + ".sc-meta{font-size:11px;color:var(--sc-muted);font-weight:400}", + ".sc-btn{cursor:pointer;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-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-chip{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-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 span{flex:1}", + ".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-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{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 input{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)}", + ":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-fg:#0c8833;--sc-success-bg:#e8f9ec;", + "--sc-destructive:#e7000b;--sc-destructive-fg:#c10007;--sc-destructive-bg:#fdecec;", + "--sc-warning-bg:#fff4e6}", + "@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}}", + ].join(""); + + var ICONS = { pass: "✓", fail: "✗", skip: "○", manual: "✋" }; + + function createPanelReporter(runInfo) { + if (typeof document === "undefined" || !document.documentElement) return null; + + var host = document.getElementById("sctest-panel-host"); + if (host) host.remove(); + host = document.createElement("div"); + host.id = "sctest-panel-host"; + document.documentElement.appendChild(host); + + var root = host.attachShadow({ mode: "open" }); + var style = document.createElement("style"); + style.textContent = PANEL_CSS; + root.appendChild(style); + + var panel = document.createElement("div"); + panel.className = "sc-panel"; + root.appendChild(panel); + + var state = { pass: 0, fail: 0, skip: 0, total: 0, manualOverrides: {} }; + var caseNodes = {}; + var suiteNodes = {}; + + function el(tag, cls, text) { + var n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + return n; + } + + // 头部 + var head = el("div", "sc-head"); + var title = el("div", "sc-title", runInfo.name); + var meta = el("div", "sc-meta", runInfo.context); + title.appendChild(meta); + head.appendChild(title); + var minBtn = el("button", "sc-btn", "—"); + minBtn.addEventListener("click", function () { + panel.dataset.min = panel.dataset.min === "1" ? "0" : "1"; + }); + head.appendChild(minBtn); + var closeBtn = el("button", "sc-btn", "×"); + closeBtn.addEventListener("click", function () { + host.remove(); + }); + head.appendChild(closeBtn); + panel.appendChild(head); + + // 概览 + var sum = el("div", "sc-sum"); + var chips = el("div", "sc-chips"); + var chipPass = el("span", "sc-chip sc-chip-pass", "通过 0"); + var chipFail = el("span", "sc-chip sc-chip-fail", "失败 0"); + var chipSkip = el("span", "sc-chip sc-chip-skip", "跳过 0"); + chips.appendChild(chipPass); + chips.appendChild(chipFail); + chips.appendChild(chipSkip); + var progress = el("div", "sc-progress"); + var barPass = el("i"); + barPass.style.background = "#34c759"; + var barFail = el("i"); + barFail.style.background = "#e7000b"; + progress.appendChild(barPass); + progress.appendChild(barFail); + sum.appendChild(chips); + sum.appendChild(progress); + panel.appendChild(sum); + + // 手动 suite 的运行控制与参数 + var manualSuites = (runInfo.suites || []).filter(function (s) { + return !s.auto; + }); + // 每个 auto:false 的 suite 各一个运行按钮 —— 见 gm_download_test.js:1083, + // 手动用例必须能与自动批次分开触发。 + manualSuites.forEach(function (s) { + var ctl = el("div", "sc-params"); + var runBtn = el("button", "sc-btn sc-btn-primary", "运行 " + s.name); + runBtn.setAttribute("data-sctest", "run-all"); + runBtn.setAttribute("data-sctest-suite", s.name); + ctl.appendChild(runBtn); + Object.keys(s.params).forEach(function (key) { + var input = document.createElement("input"); + input.value = s.params[key]; + input.setAttribute("data-sctest", "param-" + key); + input.addEventListener("input", function () { + s.params[key] = input.value; + }); + ctl.appendChild(el("span", null, key)); + ctl.appendChild(input); + }); + runBtn.addEventListener("click", function () { + runBtn.disabled = true; + if (typeof runInfo.onRunManual === "function") runInfo.onRunManual(s.name); + }); + panel.appendChild(ctl); + }); + + var body = el("div", "sc-body"); + panel.appendChild(body); + + var foot = el("div", "sc-foot"); + var sumLine = el("div", "sc-sumline", ""); + sumLine.setAttribute("data-sctest", "summary-line"); + foot.appendChild(sumLine); + var copyBtn = el("button", "sc-btn", "复制报告"); + copyBtn.addEventListener("click", function () { + var text = sumLine.textContent + "\n" + JSON.stringify(state, null, 2); + if (navigator.clipboard) navigator.clipboard.writeText(text); + }); + foot.appendChild(copyBtn); + panel.appendChild(foot); + + function recount() { + chipPass.textContent = "通过 " + state.pass; + chipFail.textContent = "失败 " + state.fail; + chipSkip.textContent = "跳过 " + state.skip; + var total = state.total || 1; + barPass.style.width = (state.pass / total) * 100 + "%"; + barFail.style.width = (state.fail / total) * 100 + "%"; + sumLine.textContent = + "总测试数: " + state.total + " 通过: " + state.pass + " 失败: " + state.fail + " 跳过: " + state.skip; + } + + function ensureSuite(name) { + if (suiteNodes[name]) return suiteNodes[name]; + var row = el("div", "sc-suite"); + row.setAttribute("data-sctest", "suite-row"); + var label = el("span", null, name); + var stat = el("span", "sc-dur", ""); + row.appendChild(label); + row.appendChild(stat); + body.appendChild(row); + var group = el("div"); + body.appendChild(group); + row.addEventListener("click", function () { + group.style.display = group.style.display === "none" ? "" : "none"; + }); + suiteNodes[name] = { group: group, stat: stat, pass: 0, total: 0 }; + return suiteNodes[name]; + } + + function applyStatus(c, node) { + node.icon.textContent = ICONS[c.status] || "○"; + node.dur.textContent = c.status === "manual" ? "人工" : c.durationMs + "ms"; + } + + return { + panelRoot: root, + onStart: function () { + state.total = (runInfo.suites || []).reduce(function (n, s) { + return n + s.cases.length; + }, 0); + recount(); + }, + onCase: function (c) { + var suite = ensureSuite(c.suite); + var row = el("div", "sc-case" + (c.status === "manual" ? " sc-case-manual" : "")); + row.setAttribute("data-sctest", "case-row"); + var icon = el("b", null, ICONS[c.status] || "○"); + var label = el("span", null, c.name); + var dur = el("i", "sc-dur", c.status === "manual" ? "人工" : c.durationMs + "ms"); + row.appendChild(icon); + row.appendChild(label); + row.appendChild(dur); + suite.group.appendChild(row); + caseNodes[c.suite + "//" + c.name] = { row: row, icon: icon, dur: dur }; + + if (c.status === "fail") { + state.fail++; + var detail = el( + "div", + "sc-detail", + "期望 " + (c.expected == null ? "-" : c.expected) + "\n实际 " + (c.actual == null ? "-" : c.actual) + "\n" + c.error + ); + detail.setAttribute("data-sctest", "failure-detail"); + suite.group.appendChild(detail); + } else if (c.status === "pass") { + state.pass++; + } else { + state.skip++; + } + + if (c.status === "manual") { + var pass = el("button", "sc-btn", "✓"); + pass.setAttribute("data-sctest", "manual-pass"); + var fail = el("button", "sc-btn", "✗"); + fail.setAttribute("data-sctest", "manual-fail"); + function settle(ok) { + state.skip--; + if (ok) state.pass++; + else state.fail++; + state.manualOverrides[c.suite + "//" + c.name] = ok ? "pass" : "fail"; + icon.textContent = ok ? ICONS.pass : ICONS.fail; + pass.remove(); + fail.remove(); + recount(); + } + pass.addEventListener("click", function () { + settle(true); + }); + fail.addEventListener("click", function () { + settle(false); + }); + row.appendChild(pass); + row.appendChild(fail); + if (c.hint) suite.group.appendChild(el("div", "sc-hint", c.hint)); + } + + applyStatus(c, caseNodes[c.suite + "//" + c.name]); + recount(); + }, + onEnd: function (summary) { + state.total = summary.total; + recount(); + }, + }; + } + + function buildReporters(opts, context, runInfo) { + var mode = opts.reporter || "auto"; + var reporters = [createConsoleReporter()]; + if (mode === "console") return reporters; + + var wantPanel = mode === "panel" || (mode === "auto" && context === "page"); + var wantLog = mode === "log" || (mode === "auto" && context !== "page"); + + if (wantPanel) { + var panel = createPanelReporter(runInfo); + if (panel) reporters.push(panel); + else wantLog = true; + } + if (wantLog) reporters.push(createLogReporter()); + return reporters; + } + + function createLogReporter() { + return { onStart: function () {}, onCase: function () {}, onEnd: function () {} }; + } + var api = { create: create, __detectContext: detectContext, - __buildReporters: function () { - return [createConsoleReporter()]; - }, + __buildReporters: buildReporters, __createConsoleReporter: createConsoleReporter, + __createPanelReporter: createPanelReporter, STATUS: STATUS, }; diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index d81d59362..4373918db 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -256,3 +256,69 @@ vdescribe("sctest 框架内核", () => { }); }); }); + +vdescribe("PanelReporter", () => { + let SCTest; + + beforeEach(async () => { + document.body.innerHTML = ""; + SCTest = await loadSCTest(); + }); + + vit("page 上下文下会挂载 Shadow DOM 宿主", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("组", () => i("a", () => e(1).toBe(1))); + await run(); + + const host = document.getElementById("sctest-panel-host"); + vexpect(host).not.toBe(null); + vexpect(host.shadowRoot).not.toBe(null); + }); + + vit("面板渲染出每条用例与汇总行", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("组一", () => { + i("通过的", () => e(1).toBe(1)); + i("失败的", () => e(1).toBe(2)); + }); + await run(); + + const root = document.getElementById("sctest-panel-host").shadowRoot; + const rows = root.querySelectorAll('[data-sctest="case-row"]'); + vexpect(rows.length).toBe(2); + vexpect(root.querySelector('[data-sctest="summary-line"]').textContent).toMatch(/通过: 1/); + vexpect(root.querySelector('[data-sctest="summary-line"]').textContent).toMatch(/失败: 1/); + }); + + vit("失败用例渲染出期望与实际", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("组", () => i("失败的", () => e("b").toBe("a"))); + await run(); + + const root = document.getElementById("sctest-panel-host").shadowRoot; + const detail = root.querySelector('[data-sctest="failure-detail"]'); + vexpect(detail.textContent).toMatch(/"a"/); + vexpect(detail.textContent).toMatch(/"b"/); + }); + + vit("人工用例渲染判定按钮,点击后计入统计", async () => { + const { describe: d, itManual: im, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("组", () => im("点一下菜单", { hint: "打开扩展菜单" })); + const summary = await run(); + vexpect(summary.skipped).toBe(1); + + const root = document.getElementById("sctest-panel-host").shadowRoot; + root.querySelector('[data-sctest="manual-pass"]').click(); + vexpect(root.querySelector('[data-sctest="summary-line"]').textContent).toMatch(/通过: 1/); + }); + + vit("auto:false 的 suite 渲染出运行按钮", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("手动组", { auto: false, params: { prefix: "sc-test-" } }, () => i("a", () => e(1).toBe(1))); + await run(); + + const root = document.getElementById("sctest-panel-host").shadowRoot; + vexpect(root.querySelector('[data-sctest="run-all"]')).not.toBe(null); + vexpect(root.querySelector('[data-sctest="param-prefix"]').value).toBe("sc-test-"); + }); +}); From cc66c5bfad72734bb262c3395486182f66811c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 14:02:31 +0800 Subject: [PATCH 04/28] =?UTF-8?q?=E2=9C=85=20=E6=B5=8B=E8=AF=95=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E6=96=B0=E5=A2=9E=20GM=5Flog=20reporter=20=E4=BE=9B?= =?UTF-8?q?=E5=90=8E=E5=8F=B0/=E5=AE=9A=E6=97=B6=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E4=BD=BF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/tests/lib/sctest.js | 55 ++++++++++++++++++++++++++- example/tests/lib/sctest.test.js | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js index b6724bd45..0a9f671fa 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -612,8 +612,60 @@ return reporters; } + function emitLog(message, level, labels) { + try { + if (typeof GM_log === "function") { + GM_log(message, level, labels); + return; + } + } catch (e) { + /* 未授权 GM_log 时静默降级,ConsoleReporter 仍然有输出 */ + } + } + function createLogReporter() { - return { onStart: function () {}, onCase: function () {}, onEnd: function () {} }; + return { + onStart: function (info) { + var cases = (info.suites || []).reduce(function (n, s) { + return n + s.cases.length; + }, 0); + emitLog("▶ " + info.name, "info", { sctest: "run", context: info.context, cases: cases }); + }, + onCase: function (c) { + if (c.status === STATUS.PASS) { + emitLog("✓ " + c.suite + " › " + c.name, "info", { + sctest: "case", + status: "pass", + ms: c.durationMs, + }); + } else if (c.status === STATUS.FAIL) { + emitLog("✗ " + c.suite + " › " + c.name + " — " + c.error, "error", { + sctest: "case", + status: "fail", + suite: c.suite, + }); + } else { + emitLog("○ " + c.suite + " › " + c.name, "warn", { sctest: "case", status: "skip" }); + } + }, + onEnd: function (summary) { + emitLog( + "■ 总测试数: " + + summary.total + + " 通过: " + + summary.passed + + " 失败: " + + summary.failed + + " 跳过: " + + summary.skipped + + " (" + + summary.durationMs + + "ms)", + "info", + { sctest: "summary", passed: summary.passed, failed: summary.failed } + ); + }, + }; } var api = { @@ -622,6 +674,7 @@ __buildReporters: buildReporters, __createConsoleReporter: createConsoleReporter, __createPanelReporter: createPanelReporter, + __createLogReporter: createLogReporter, STATUS: STATUS, }; diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index 4373918db..575915cfa 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -322,3 +322,67 @@ vdescribe("PanelReporter", () => { vexpect(root.querySelector('[data-sctest="param-prefix"]').value).toBe("sc-test-"); }); }); + +vdescribe("LogReporter", () => { + let SCTest; + let calls; + + beforeEach(async () => { + calls = []; + globalThis.GM_log = (message, level, labels) => calls.push({ message, level, labels }); + SCTest = await loadSCTest(); + }); + + vit("每条用例发一条 GM_log,结果写进 label", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "log" }); + d("存储", () => { + i("写入", () => e(1).toBe(1)); + i("读取", () => e(1).toBe(2)); + }); + await run(); + + const cases = calls.filter((c) => c.labels && c.labels.sctest === "case"); + vexpect(cases.length).toBe(2); + vexpect(cases[0].level).toBe("info"); + vexpect(cases[0].labels.status).toBe("pass"); + vexpect(cases[1].level).toBe("error"); + vexpect(cases[1].labels.status).toBe("fail"); + }); + + vit("汇总是单条日志,label 带 passed/failed", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "log" }); + d("组", () => i("a", () => e(1).toBe(1))); + await run(); + + const summaries = calls.filter((c) => c.labels && c.labels.sctest === "summary"); + vexpect(summaries.length).toBe(1); + vexpect(summaries[0].labels.passed).toBe(1); + vexpect(summaries[0].labels.failed).toBe(0); + vexpect(summaries[0].message).toMatch(/总测试数: 1/); + }); + + vit("每条日志的 label 不超过 4 个键", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "log" }); + d("组", () => i("a", () => e(1).toBe(1))); + await run(); + calls.forEach((c) => vexpect(Object.keys(c.labels || {}).length).toBeLessThanOrEqual(4)); + }); + + vit("GM_log 未授权时不抛异常", async () => { + delete globalThis.GM_log; + SCTest = await loadSCTest(); + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "log" }); + d("组", () => i("a", () => e(1).toBe(1))); + await vexpect(run()).resolves.toBeTruthy(); + }); + + vit("auto 模式下 @crontab 脚本选用 LogReporter", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ + name: "demo", + context: "crontab", + }); + d("组", () => i("a", () => e(1).toBe(1))); + await run(); + vexpect(calls.filter((c) => c.labels && c.labels.sctest === "summary").length).toBe(1); + }); +}); From b7434445bc11ed51ada936b52effb29c60c85969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 14:11:55 +0800 Subject: [PATCH 05/28] =?UTF-8?q?=F0=9F=90=9B=20=E7=A7=BB=E9=99=A4=20emitL?= =?UTF-8?q?og=20=E4=B8=AD=E5=90=9E=E5=BC=82=E5=B8=B8=E7=9A=84=20try/catch,?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=E8=B7=B3=E8=BF=87/=E5=BC=80=E5=A7=8B?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typeof GM_log === "function" 的判断已完整覆盖未 @grant GM_log 的降级场景, 外层 try/catch 实际只是把已授权 GM_log 抛出的真实异常静默吞掉。移除 try/catch,让已授权 GM_log 的异常正常抛出。 同时为开始日志(sctest:"run")与跳过/人工用例日志(sctest:"case", status:"skip")补上内容校验断言 —— 此前只有 key 数量断言,不会在 level/label 内容错误时失败。 --- example/tests/lib/sctest.js | 9 ++------- example/tests/lib/sctest.test.js | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js index 0a9f671fa..8053085f7 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -613,13 +613,8 @@ } function emitLog(message, level, labels) { - try { - if (typeof GM_log === "function") { - GM_log(message, level, labels); - return; - } - } catch (e) { - /* 未授权 GM_log 时静默降级,ConsoleReporter 仍然有输出 */ + if (typeof GM_log === "function") { + GM_log(message, level, labels); } } diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index 575915cfa..af8c94d4f 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -333,6 +333,28 @@ vdescribe("LogReporter", () => { SCTest = await loadSCTest(); }); + vit("开始日志的 level 与 label 符合约定,cases 计入注册的用例总数", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ + name: "demo", + reporter: "log", + context: "background", + }); + d("组一", () => { + i("a", () => e(1).toBe(1)); + i("b", () => e(1).toBe(1)); + }); + d("组二", () => { + i("c", () => e(1).toBe(1)); + }); + await run(); + + const starts = calls.filter((c) => c.labels && c.labels.sctest === "run"); + vexpect(starts.length).toBe(1); + vexpect(starts[0].level).toBe("info"); + vexpect(starts[0].labels.context).toBe("background"); + vexpect(starts[0].labels.cases).toBe(3); + }); + vit("每条用例发一条 GM_log,结果写进 label", async () => { const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "log" }); d("存储", () => { @@ -349,6 +371,17 @@ vdescribe("LogReporter", () => { vexpect(cases[1].labels.status).toBe("fail"); }); + vit("跳过/人工用例发出 warn 级别日志,label 标记 status: skip", async () => { + const { describe: d, itManual: im, run } = SCTest.create({ name: "demo", reporter: "log" }); + d("组", () => im("待人工确认的用例")); + await run(); + + const cases = calls.filter((c) => c.labels && c.labels.sctest === "case"); + vexpect(cases.length).toBe(1); + vexpect(cases[0].level).toBe("warn"); + vexpect(cases[0].labels.status).toBe("skip"); + }); + vit("汇总是单条日志,label 带 passed/failed", async () => { const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "log" }); d("组", () => i("a", () => e(1).toBe(1))); From ed52b93dba5aa40734e2c9419d18d48fbf47e123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 14:23:37 +0800 Subject: [PATCH 06/28] =?UTF-8?q?=E2=9C=85=20=E6=B5=8B=E8=AF=95=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E8=A1=A5=E9=BD=90=E6=89=8B=E5=8A=A8=20suite=20?= =?UTF-8?q?=E9=87=8D=E8=B7=91=E9=80=9A=E8=B7=AF=E4=B8=8E=E7=94=A8=E6=B3=95?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/tests/lib/README.md | 70 ++++++++++++++++++++++++++++++++ example/tests/lib/sctest.js | 40 ++++++++++++++++-- example/tests/lib/sctest.test.js | 36 ++++++++++++++++ 3 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 example/tests/lib/README.md diff --git a/example/tests/lib/README.md b/example/tests/lib/README.md new file mode 100644 index 000000000..e6ea0a668 --- /dev/null +++ b/example/tests/lib/README.md @@ -0,0 +1,70 @@ +# sctest — example/tests 共用测试框架 + +零依赖、零构建的单文件测试框架,供 `example/tests/` 下的用户脚本共用。 + +## 引入 + +```js +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@main/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?)` | 被测目标须为函数;可选校验异常消息 | + +## 展示通道 + +三个 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 index 8053085f7..b44877707 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -257,11 +257,30 @@ }; } + async function runManualSuites(reporters, onlySuiteName) { + for (var i = 0; i < suites.length; i++) { + var suite = suites[i]; + if (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); + } + } + } + async function run() { - var reporters = global.SCTest.__buildReporters(opts, context, { suites: suites, name: runName }); + var runInfo = { name: runName, context: context, suites: suites, onRunManual: null }; + var reporters = global.SCTest.__buildReporters(opts, context, runInfo); + runInfo.onRunManual = function (suiteName) { + return runManualSuites(reporters, suiteName); + }; var startedAt = now(); reporters.forEach(function (r) { - if (r.onStart) r.onStart({ name: runName, context: context, suites: suites }); + if (r.onStart) r.onStart(runInfo); }); for (var i = 0; i < suites.length; i++) { @@ -532,6 +551,21 @@ recount(); }, onCase: function (c) { + var key = c.suite + "//" + c.name; + var existing = caseNodes[key]; + if (existing) { + if (existing.status === "skip") state.skip--; + else if (existing.status === "pass") state.pass--; + else if (existing.status === "fail") state.fail--; + existing.status = c.status; + existing.icon.textContent = ICONS[c.status] || "○"; + existing.dur.textContent = c.status === "manual" ? "人工" : c.durationMs + "ms"; + if (c.status === "pass") state.pass++; + else if (c.status === "fail") state.fail++; + else state.skip++; + recount(); + return; + } var suite = ensureSuite(c.suite); var row = el("div", "sc-case" + (c.status === "manual" ? " sc-case-manual" : "")); row.setAttribute("data-sctest", "case-row"); @@ -542,7 +576,7 @@ row.appendChild(label); row.appendChild(dur); suite.group.appendChild(row); - caseNodes[c.suite + "//" + c.name] = { row: row, icon: icon, dur: dur }; + caseNodes[c.suite + "//" + c.name] = { row: row, icon: icon, dur: dur, status: c.status }; if (c.status === "fail") { state.fail++; diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index af8c94d4f..77ca53c30 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -275,6 +275,15 @@ vdescribe("PanelReporter", () => { vexpect(host.shadowRoot).not.toBe(null); }); + vit("面板头部副标题显示运行上下文,而非空白", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("组", () => i("a", () => e(1).toBe(1))); + await run(); + + const root = document.getElementById("sctest-panel-host").shadowRoot; + vexpect(root.querySelector(".sc-meta").textContent).toBe("page"); + }); + vit("面板渲染出每条用例与汇总行", async () => { const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); d("组一", () => { @@ -419,3 +428,30 @@ vdescribe("LogReporter", () => { vexpect(calls.filter((c) => c.labels && c.labels.sctest === "summary").length).toBe(1); }); }); + +vdescribe("手动 suite 触发", () => { + let SCTest; + + beforeEach(async () => { + document.body.innerHTML = ""; + SCTest = await loadSCTest(); + }); + + vit("点击运行全部后手动 suite 真正执行并更新统计", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("手动组", { auto: false }, () => { + i("会通过", () => e(1).toBe(1)); + i("会失败", () => e(1).toBe(2)); + }); + const summary = await run(); + vexpect(summary.skipped).toBe(2); + + const root = document.getElementById("sctest-panel-host").shadowRoot; + root.querySelector('[data-sctest="run-all"]').click(); + await new Promise((r) => setTimeout(r, 0)); + + const line = root.querySelector('[data-sctest="summary-line"]').textContent; + vexpect(line).toMatch(/通过: 1/); + vexpect(line).toMatch(/失败: 1/); + }); +}); From 58f7ea5c88c52c0f5bdfa8045837b4873f0a4633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 14:43:52 +0800 Subject: [PATCH 07/28] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=A4=B1=E8=B4=A5=E8=AF=A6=E6=83=85=E5=9C=A8?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=20suite=20=E9=87=8D=E8=B7=91=E6=97=B6?= =?UTF-8?q?=E4=B8=8D=E6=B8=B2=E6=9F=93=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onCase 的更新分支此前只更新图标/耗时/统计,从不生成 .sc-detail, 而 auto:false 的 suite 每个用例首次真正执行时都会先被预渲染成 skip、 从而永远走这条分支——失败用例因此从不展示期望/实际/错误详情。 新增 renderDetail 统一由两条分支调用,重跑时先移除旧详情再按需重建, 避免重复追加;更新分支同时改用既有的 applyStatus 消掉重复表达式。 强化重跑用例的断言,校验行数不翻倍、跳过数清零,并补充详情展示与 失败转通过后详情清除的覆盖。 --- example/tests/lib/sctest.js | 37 ++++++++++++------ example/tests/lib/sctest.test.js | 65 +++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js index b44877707..8cabbf0fa 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -542,6 +542,26 @@ node.dur.textContent = c.status === "manual" ? "人工" : c.durationMs + "ms"; } + // 渲染/清理失败详情框。挂在 node.detail 上以便重跑时能先移除旧的一份, + // 而不是无限追加——manual suite 的用例首次总是先以 SKIP 预渲染, + // 真正执行时都会走 onCase 的"更新"分支,所以两个分支都要能产生/替换详情框。 + function renderDetail(node, c) { + if (node.detail) { + node.detail.remove(); + node.detail = null; + } + if (c.status === "fail") { + var detail = el( + "div", + "sc-detail", + "期望 " + (c.expected == null ? "-" : c.expected) + "\n实际 " + (c.actual == null ? "-" : c.actual) + "\n" + c.error + ); + detail.setAttribute("data-sctest", "failure-detail"); + node.row.parentNode.insertBefore(detail, node.row.nextSibling); + node.detail = detail; + } + } + return { panelRoot: root, onStart: function () { @@ -558,8 +578,8 @@ else if (existing.status === "pass") state.pass--; else if (existing.status === "fail") state.fail--; existing.status = c.status; - existing.icon.textContent = ICONS[c.status] || "○"; - existing.dur.textContent = c.status === "manual" ? "人工" : c.durationMs + "ms"; + applyStatus(c, existing); + renderDetail(existing, c); if (c.status === "pass") state.pass++; else if (c.status === "fail") state.fail++; else state.skip++; @@ -576,22 +596,17 @@ row.appendChild(label); row.appendChild(dur); suite.group.appendChild(row); - caseNodes[c.suite + "//" + c.name] = { row: row, icon: icon, dur: dur, status: c.status }; + var node = { row: row, icon: icon, dur: dur, status: c.status, detail: null }; + caseNodes[key] = node; if (c.status === "fail") { state.fail++; - var detail = el( - "div", - "sc-detail", - "期望 " + (c.expected == null ? "-" : c.expected) + "\n实际 " + (c.actual == null ? "-" : c.actual) + "\n" + c.error - ); - detail.setAttribute("data-sctest", "failure-detail"); - suite.group.appendChild(detail); } else if (c.status === "pass") { state.pass++; } else { state.skip++; } + renderDetail(node, c); if (c.status === "manual") { var pass = el("button", "sc-btn", "✓"); @@ -619,7 +634,7 @@ if (c.hint) suite.group.appendChild(el("div", "sc-hint", c.hint)); } - applyStatus(c, caseNodes[c.suite + "//" + c.name]); + applyStatus(c, node); recount(); }, onEnd: function (summary) { diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index 77ca53c30..88ae08638 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -437,7 +437,7 @@ vdescribe("手动 suite 触发", () => { SCTest = await loadSCTest(); }); - vit("点击运行全部后手动 suite 真正执行并更新统计", async () => { + vit("点击运行全部后手动 suite 真正执行并更新统计,不重复渲染行且跳过数清零", async () => { const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); d("手动组", { auto: false }, () => { i("会通过", () => e(1).toBe(1)); @@ -453,5 +453,68 @@ vdescribe("手动 suite 触发", () => { const line = root.querySelector('[data-sctest="summary-line"]').textContent; vexpect(line).toMatch(/通过: 1/); vexpect(line).toMatch(/失败: 1/); + // 跳过预渲染时两个用例都已各建一行;真正执行后不应再追加新行(否则总数 4 行也能匹配上面两条正则)。 + vexpect(root.querySelectorAll('[data-sctest="case-row"]').length).toBe(2); + // 两个用例都已真正跑完,跳过数必须清零,而不是停留在预渲染时的 2。 + vexpect(line).toMatch(/跳过: 0/); + }); + + vit("auto:false suite 用例首次真正执行失败时,面板暴露期望与实际的详情框", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("手动组", { auto: false }, () => { + i("会失败", () => e("b").toBe("a")); + }); + await run(); + + const root = document.getElementById("sctest-panel-host").shadowRoot; + // 跳过预渲染阶段不应该有详情框。 + vexpect(root.querySelector('[data-sctest="failure-detail"]')).toBe(null); + + root.querySelector('[data-sctest="run-all"]').click(); + await new Promise((r) => setTimeout(r, 0)); + + const detail = root.querySelector('[data-sctest="failure-detail"]'); + vexpect(detail).not.toBe(null); + vexpect(detail.textContent).toMatch(/"a"/); + vexpect(detail.textContent).toMatch(/"b"/); + }); + + vit("用例重跑:连续失败只保留一份最新详情,失败转通过后旧详情被清除", async () => { + let attempt = 0; + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("手动组", { auto: false }, () => { + i("先失败两次后通过", () => { + attempt++; + if (attempt === 1) e("b").toBe("a"); + else if (attempt === 2) e("d").toBe("c"); + }); + }); + await run(); + + const root = document.getElementById("sctest-panel-host").shadowRoot; + const runBtn = root.querySelector('[data-sctest="run-all"]'); + + runBtn.click(); + await new Promise((r) => setTimeout(r, 0)); + let details = root.querySelectorAll('[data-sctest="failure-detail"]'); + vexpect(details.length).toBe(1); + vexpect(details[0].textContent).toMatch(/"a"/); + vexpect(details[0].textContent).toMatch(/"b"/); + + // 面板按钮点一次后会被禁用,这里手动复位只为了在单个面板实例里驱动第二次真正执行, + // 验证的是 runManualSuites/onCase 状态机本身,而非模拟用户重复点击。 + runBtn.disabled = false; + runBtn.click(); + await new Promise((r) => setTimeout(r, 0)); + details = root.querySelectorAll('[data-sctest="failure-detail"]'); + vexpect(details.length).toBe(1); + vexpect(details[0].textContent).toMatch(/"c"/); + vexpect(details[0].textContent).toMatch(/"d"/); + + runBtn.disabled = false; + runBtn.click(); + await new Promise((r) => setTimeout(r, 0)); + details = root.querySelectorAll('[data-sctest="failure-detail"]'); + vexpect(details.length).toBe(0); }); }); From bb5bbdcf5581762ae1e696f44873919f876f3ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 14:55:42 +0800 Subject: [PATCH 08/28] =?UTF-8?q?=E2=9C=85=20e2e=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8A=8A=E6=B5=8B=E8=AF=95=E6=A1=86=E6=9E=B6=20@require=20?= =?UTF-8?q?=E9=87=8D=E5=86=99=E5=88=B0=E6=9C=AC=E5=9C=B0=20mock=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/gm-api.spec.ts | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 9e30f5ddc..472ccc44b 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -154,6 +154,13 @@ 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; + } + const bytesMatch = url.pathname.match(/^\/bytes\/(\d+)$/); if (bytesMatch) { const size = Number(bytesMatch[1]); @@ -217,6 +224,14 @@ function patchTargetMatchCode(code: string, targetUrl: string): string { ); } +// 把框架的 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 @@ -237,10 +252,11 @@ async function runTestScript( scriptFile: string, targetUrl: string, timeoutMs: number, - options?: { patchCode?: (code: string) => string } + options?: { patchCode?: (code: string) => string; requireOrigin?: string } ): 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; @@ -312,7 +328,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 +346,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 +363,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 +381,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}`); @@ -382,7 +400,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}`); @@ -399,7 +417,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}`); From 79f761e3091efd2e883fbe01029e800221d95464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 15:20:44 +0800 Subject: [PATCH 09/28] =?UTF-8?q?=E2=9C=85=20gm=5Fapi=5Fsync=5Ftest=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0=E5=85=B1=E7=94=A8=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/tests/gm_api_sync_test.js | 583 +++++++++--------------------- 1 file changed, 163 insertions(+), 420 deletions(-) diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index 7b6008ce2..a699bc025 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@main/example/tests/lib/sctest.js // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbun.com // @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; @@ -162,14 +113,11 @@ // 先设置初始值,然后再添加监听器 await GM.setValue("test_listener", "initial"); - console.log("已设置初始值: initial"); // 使用 setTimeout 确保初始值已完全设置 setTimeout(() => { // 添加监听器 listenerId = GM_addValueChangeListener("test_listener", (name, oldValue, newValue, remote) => { - console.log(`值变化监听器触发: ${name}, ${oldValue} -> ${newValue}, remote: ${remote}`); - // 清除超时 if (timeoutId) { clearTimeout(timeoutId); @@ -177,12 +125,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") { @@ -206,105 +152,92 @@ reject(new Error(`监听器ID类型错误: 期望 number 或 string, 实际 ${idType}`)); return; } - console.log("监听器已注册,ID:", listenerId); // 延迟后修改值触发监听器 setTimeout(() => { GM_setValue("test_listener", "changed"); - console.log("已修改值为: changed"); }, 100); }, 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(); - // 创建脚本元素测试 - 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";', }); - }); - assert(true, img && img.tagName === "IMG", "应该返回 img 元素"); - console.log("添加的图片元素:", img); - - // 3秒后移除 - setTimeout(() => { - script.remove(); - div.remove(); - img.remove(); - }, 3000); - }); + expect(script && script.tagName === "SCRIPT").toBeTruthy(); + expect(unsafeWindow.foo).toBe("bar"); - // ============ GM_getResourceText/URL 测试 ============ - console.log("\n%c--- GM 资源 API 测试 ---", "color: orange; font-weight: bold;"); + document.querySelector(".container").insertBefore(script, document.querySelector(".masthead")); - test("GM_getResourceText", () => { - assert("function", typeof GM_getResourceText, "GM_getResourceText 应该是函数"); + // 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(); - const css = GM_getResourceText("testCSS"); - assert("string", typeof css, "应该返回字符串"); - assert(163870, css.length, "资源内容长度应该是 163870"); - console.log("资源文本长度:", css.length); + // 3秒后移除 + setTimeout(() => { + script.remove(); + div.remove(); + img.remove(); + }, 3000); + }); }); - test("GM_getResourceURL", () => { - assert("function", typeof GM_getResourceURL, "GM_getResourceURL 应该是函数"); + describe("GM 资源 API", () => { + it("GM_getResourceText", () => { + expect(GM_getResourceText).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 css = GM_getResourceText("testCSS"); + expect(css).toBeTypeOf("string"); + expect(css.length).toBe(163870); + }); - // ============ GM_xmlhttpRequest 测试 ============ - console.log("\n%c--- GM 网络请求 API 测试 ---", "color: orange; font-weight: bold;"); + it("GM_getResourceURL", () => { + expect(GM_getResourceURL).toBeTypeOf("function"); - (async () => { - await testAsync("GM_xmlhttpRequest - GET 请求", () => { + const url = GM_getResourceURL("testCSS"); + expect(url).toBeTypeOf("string"); + expect(url.startsWith("data:") || url.startsWith("blob:")).toBeTruthy(); + }); + }); + + describe("GM 网络请求 API", () => { + it("GM_xmlhttpRequest - GET 请求", () => { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", @@ -312,12 +245,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://httpbun.com/get", data.url, "响应应该包含 url 字段"); - console.log("httpbun 响应信息:", data.url); + expect(data).toBeTypeOf("object"); + expect(data.url).toBe("https://httpbun.com/get"); resolve(); } catch (error) { reject(error); @@ -332,12 +264,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 +280,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 +326,6 @@ if (error) { reject(new Error("设置 cookie 失败: " + error)); } else { - console.log("Cookie 已设置: scriptcat_test1 @ example.com"); resolve(); } } @@ -410,7 +333,7 @@ }); }); - await testAsync("GM_cookie - 回调风格 set (带 domain 和 path)", () => { + it("GM_cookie - 回调风格 set (带 domain 和 path)", () => { return new Promise((resolve, reject) => { GM_cookie( "set", @@ -425,7 +348,6 @@ if (error) { reject(new Error("设置 cookie 失败: " + error)); } else { - console.log("Cookie 已设置: scriptcat_test2 @ .example.com/path"); resolve(); } } @@ -433,7 +355,7 @@ }); }); - await testAsync("GM_cookie - 回调风格 list (by domain)", () => { + it("GM_cookie - 回调风格 list (by domain)", () => { return new Promise((resolve, reject) => { GM_cookie( "list", @@ -445,10 +367,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 +379,7 @@ }); }); - await testAsync("GM_cookie - 回调风格 list (by url)", () => { + it("GM_cookie - 回调风格 list (by url)", () => { return new Promise((resolve, reject) => { GM_cookie( "list", @@ -471,8 +391,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 +402,7 @@ }); }); - await testAsync("GM_cookie - 回调风格 delete", () => { + it("GM_cookie - 回调风格 delete", () => { return new Promise((resolve, reject) => { GM_cookie( "delete", @@ -495,7 +414,6 @@ if (error) { reject(new Error("删除 cookie 失败: " + error)); } else { - console.log("Cookie 已删除: scriptcat_test2"); resolve(); } } @@ -503,7 +421,7 @@ }); }); - await testAsync("GM_cookie - 验证删除后", () => { + it("GM_cookie - 验证删除后", () => { return new Promise((resolve, reject) => { GM_cookie( "list", @@ -516,8 +434,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 +446,7 @@ }); // 清理所有测试 cookies - await testAsync("清理测试 cookies", () => { + it("清理测试 cookies", () => { return new Promise((resolve, reject) => { GM_cookie("list", { domain: "example.com" }, (cookies, error) => { if (error) { @@ -540,7 +457,6 @@ const testCookies = cookies.filter((c) => c.name.startsWith("scriptcat_test")); if (testCookies.length === 0) { - console.log("没有需要清理的测试 cookies"); resolve(); return; } @@ -559,7 +475,6 @@ console.warn(`删除 cookie ${cookie.name} 失败:`, error); } if (deleteCount === testCookies.length) { - console.log(`已清理 ${testCookies.length} 个测试 cookies`); resolve(); } } @@ -568,193 +483,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(); })(); From dbf482ed1afc27ccbb17bcec6dd8f04fb0090551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 15:40:44 +0800 Subject: [PATCH 10/28] =?UTF-8?q?=F0=9F=90=9B=20=E6=81=A2=E5=A4=8D=20gm=5F?= =?UTF-8?q?api=5Fsync=5Ftest=20=E6=96=AD=E8=A8=80=E5=89=8D=E7=9A=84?= =?UTF-8?q?=E8=BF=87=E7=A8=8B=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GM_addValueChangeListener/GM_addElement 迁移时被误删的三条+两条日志, 均在断言之前/之间无条件执行,超时或挂起时仍会打印,是排查这两个 用例卡住位置的唯一线索,应予保留。 --- example/tests/gm_api_sync_test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index a699bc025..1c5006595 100644 --- a/example/tests/gm_api_sync_test.js +++ b/example/tests/gm_api_sync_test.js @@ -113,6 +113,7 @@ // 先设置初始值,然后再添加监听器 await GM.setValue("test_listener", "initial"); + console.log("已设置初始值: initial"); // 使用 setTimeout 确保初始值已完全设置 setTimeout(() => { @@ -152,10 +153,12 @@ reject(new Error(`监听器ID类型错误: 期望 number 或 string, 实际 ${idType}`)); return; } + console.log("监听器已注册,ID:", listenerId); // 延迟后修改值触发监听器 setTimeout(() => { GM_setValue("test_listener", "changed"); + console.log("已修改值为: changed"); }, 100); }, 50); }); @@ -184,6 +187,7 @@ 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", { @@ -191,6 +195,7 @@ }); expect(script && script.tagName === "SCRIPT").toBeTruthy(); expect(unsafeWindow.foo).toBe("bar"); + console.log("添加的脚本元素:", script); document.querySelector(".container").insertBefore(script, document.querySelector(".masthead")); From 5263027d0c58dafe00b7dea89b0b495651b51dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 15:53:10 +0800 Subject: [PATCH 11/28] =?UTF-8?q?=E2=9C=85=20gm=5Fapi=5Fasync=5Ftest=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0=E5=85=B1=E7=94=A8=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/tests/gm_api_async_test.js | 679 ++++++++++------------------- 1 file changed, 225 insertions(+), 454 deletions(-) diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index c8535935c..2a6e033f0 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@main/example/tests/lib/sctest.js // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab // @connect httpbun.com // @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://httpbun.com/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://httpbun.com/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://httpbun.com/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://httpbun.com/get", data.url, "响应应该包含 url 字段"); - console.log("httpbun 响应信息:", 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://httpbun.com/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(); })(); From fa8f0ae29e23920f098853ac7b9ca3d64040626a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 16:12:32 +0800 Subject: [PATCH 12/28] =?UTF-8?q?=E2=9C=85=20inject=5Fcontent=20=E4=B8=8E?= =?UTF-8?q?=20early=5Finject=20=E7=B3=BB=E5=88=97=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=E5=85=B1=E7=94=A8=E6=B5=8B=E8=AF=95=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移前后计数(全部一致): - inject_content_test.js: e2e passed=11 failed=0 → passed=11 failed=0 - early_inject_content_test.js: 总计 14 通过 14 失败 0 → 总测试数 14 通过 14 失败 0 - early_inject_page_test.js: 总计 14 通过 14 失败 0 → 总测试数 14 通过 14 失败 0 后两个文件无 e2e 覆盖,用一次性 Playwright scratch 脚本核对。 两个 early_inject 文件显式指定 reporter: "console":它们断言 document-start 时 DOM 保持原始态,而面板会往 document.documentElement 挂 #sctest-panel-host, 正好破坏 expect(firstElement.innerHTML).toBe("") 这条断言。这两个文件因此 不显示页面面板——注入型 DOM 面板与 DOM 纯净断言无法共存。 --- example/tests/early_inject_content_test.js | 278 ++++++----------- example/tests/early_inject_page_test.js | 339 ++++++++------------- example/tests/inject_content_test.js | 196 +++++------- 3 files changed, 304 insertions(+), 509 deletions(-) diff --git a/example/tests/early_inject_content_test.js b/example/tests/early_inject_content_test.js index ad28dcfed..303af34e1 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@main/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..595243322 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@main/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/inject_content_test.js b/example/tests/inject_content_test.js index e34dd5805..dff82ef0b 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@main/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(); })(); From c6af8c89aef9da0cd1c9adafca1a31faeeef01e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 16:38:41 +0800 Subject: [PATCH 13/28] =?UTF-8?q?=E2=9C=85=20sandbox=5Ftest=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=E5=85=B1=E7=94=A8=E6=B5=8B=E8=AF=95=E6=A1=86?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2e gate (e2e/gm-api.spec.ts -g "Sandbox Test"): passed=32, failed=0 before migration; passed=32, failed=0 after migration. N unchanged. --- example/tests/sandbox_test.js | 1383 ++++++++++++++------------------- 1 file changed, 571 insertions(+), 812 deletions(-) diff --git a/example/tests/sandbox_test.js b/example/tests/sandbox_test.js index 405aae470..155115add 100644 --- a/example/tests/sandbox_test.js +++ b/example/tests/sandbox_test.js @@ -19,6 +19,7 @@ // @grant window.close // @grant window.focus // @grant unsafeWindow +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@main/example/tests/lib/sctest.js // @run-at document-end // ==/UserScript== @@ -30,17 +31,9 @@ mpt.name = "test-element-1002"; (async function () { "use strict"; - const markerPrefix = `__scriptcat_sandbox_${Date.now()}_${Math.random().toString(36).slice(2)}`; - const testResults = { - passed: 0, - failed: 0, - total: 0, - }; + const { describe, it, expect, run } = SCTest.create({ name: "半沙盒环境测试" }); - console.log( - "%c=== 半沙盒环境测试开始 ===", - "color: blue; font-size: 16px; font-weight: bold;", - ); + const markerPrefix = `__scriptcat_sandbox_${Date.now()}_${Math.random().toString(36).slice(2)}`; function formatValue(value) { if (value === window) return "[sandbox window]"; @@ -57,6 +50,8 @@ mpt.name = "test-element-1002"; } } + // assertSame 只保留给 assertThrowsOrKeepsValue 内部使用(其实现不改,见下); + // 其余断言点已迁移为 expect(actual).toBe(expected)。 function assertSame(expected, actual, message) { if (!Object.is(expected, actual)) { throw new Error( @@ -66,15 +61,11 @@ mpt.name = "test-element-1002"; } function assertNotSame(unexpected, actual, message) { - if (Object.is(unexpected, actual)) { - throw new Error(`${message} - 不应等于 ${formatValue(unexpected)}`); + if (unexpected === actual) { + throw new Error((message || "断言失败") + " - 不应等于 " + JSON.stringify(unexpected)); } } - function assertTrue(condition, message) { - if (!condition) throw new Error(message || "断言失败: 条件不为真"); - } - function assertThrowsOrKeepsValue(assign, read, expected, message) { let threw = false; try { @@ -89,24 +80,6 @@ mpt.name = "test-element-1002"; ); } - async function test(name, fn) { - testResults.total++; - try { - await fn(); - testResults.passed++; - console.log(`%cPASS ${name}`, "color: green;"); - return true; - } catch (error) { - testResults.failed++; - console.error(`%cFAIL ${name}`, "color: red;", error); - return false; - } - } - - function section(name) { - console.log(`\n%c--- ${name} ---`, "color: orange; font-weight: bold;"); - } - function waitForEventLoop() { return new Promise((resolve) => setTimeout(resolve, 0)); } @@ -148,809 +121,595 @@ mpt.name = "test-element-1002"; } } - section("沙盒全局身份"); - - await test("检测全局 testVar1001 会否跳出沙盒", () => { - assertSame(undefined, window["testVar1001"], "全局 testVar1001 不应跳出沙盒"); - assertSame(undefined, unsafeWindow["testVar1001"], "全局 testVar1001 不应跳出沙盒"); - }); - - await test("检测全局 test-element-1002", () => { - assertSame("test-element-1002", unsafeWindow["test-element-1002"]?.id, "全局 test-element-1002"); - assertSame(undefined, window["test-element-1002"]?.id, "半沙盒无法检测 test-element-1002"); - }); + describe("沙盒全局身份", () => { + it("检测全局 testVar1001 会否跳出沙盒", () => { + expect(window["testVar1001"]).toBe(undefined); + expect(unsafeWindow["testVar1001"]).toBe(undefined); + }); - await test("window/self/globalThis/top/parent/frames 均指向沙盒对象", () => { - assertSame("object", typeof unsafeWindow, "unsafeWindow 应存在"); - assertNotSame( - unsafeWindow, - window, - "默认 grant 环境下 window 不应是页面 window", - ); - assertSame(window, self, "self 应指向沙盒 window"); - assertSame(window, globalThis, "globalThis 应指向沙盒 window"); - assertSame(window, top, "top 应指向沙盒 window"); - assertSame(window, parent, "parent 应指向沙盒 window"); - assertSame(window, frames, "frames 应指向沙盒 window"); - assertSame( - "[object Window]", - Object.prototype.toString.call(window), - "沙盒 window 应保持 Window 标记", - ); - }); + it("检测全局 test-element-1002", () => { + expect(unsafeWindow["test-element-1002"]?.id).toBe("test-element-1002"); + expect(window["test-element-1002"]?.id).toBe(undefined); + }); - await test("沙盒 window 使用空原型,但保留页面 Window 外观 (Issue #962)", () => { - assertSame( - null, - Object.getPrototypeOf(window), - "沙盒 window 的原型应为空,避免 Object.prototype 污染穿透", - ); - assertSame( - unsafeWindow.constructor, - window.constructor, - "constructor 应与页面 Window 构造器一致", - ); - assertSame( - unsafeWindow.__proto__, - window.__proto__, - "__proto__ 应暴露页面 Window 原型用于兼容", - ); - assertSame( - false, - window instanceof unsafeWindow.constructor, - "沙盒 window 不应是真实 Window 实例", - ); - }); + it("window/self/globalThis/top/parent/frames 均指向沙盒对象", () => { + expect(typeof unsafeWindow).toBe("object"); + assertNotSame( + unsafeWindow, + window, + "默认 grant 环境下 window 不应是页面 window", + ); + expect(self).toBe(window); + expect(globalThis).toBe(window); + expect(top).toBe(window); + expect(parent).toBe(window); + expect(frames).toBe(window); + expect(Object.prototype.toString.call(window)).toBe("[object Window]"); + }); - await test("页面 DOM getter 返回真实页面对象", () => { - assertSame(unsafeWindow.document, document, "document 应是页面 document"); - assertSame( - unsafeWindow.location.href, - location.href, - "location 应读取页面地址", - ); - assertSame( - unsafeWindow.document.documentElement, - document.documentElement, - "DOM 节点身份应与页面一致", - ); - }); + it("沙盒 window 使用空原型,但保留页面 Window 外观 (Issue #962)", () => { + expect(Object.getPrototypeOf(window)).toBe(null); + expect(window.constructor).toBe(unsafeWindow.constructor); + expect(window.__proto__).toBe(unsafeWindow.__proto__); + expect(window instanceof unsafeWindow.constructor).toBe(false); + }); - await test("页面全局变量不会自动穿透到沙盒 window", () => - withCleanup( - () => { - const key = `${markerPrefix}_page_global`; - unsafeWindow[key] = "page-value"; - assertSame( - "page-value", - unsafeWindow[key], - "页面变量应写入 unsafeWindow", - ); - assertSame(undefined, window[key], "页面变量不应出现在沙盒 window"); - - window[key] = "sandbox-value"; - assertSame("sandbox-value", window[key], "沙盒变量应写入沙盒 window"); - assertSame("page-value", unsafeWindow[key], "沙盒变量不应覆盖页面变量"); - }, - () => { - delete window[`${markerPrefix}_page_global`]; - delete unsafeWindow[`${markerPrefix}_page_global`]; - }, - )); - - await test("页面 DOM named property 不应穿透为沙盒全局变量 (Issue #273, #700)", () => - withCleanup( - () => { - const id = `${markerPrefix}_named_element`; - const div = document.createElement("div"); - div.id = id; - document.body.appendChild(div); - - assertSame( - div, - unsafeWindow[id], - "页面 window 应可通过 named property 访问元素", - ); - assertSame( - undefined, - window[id], - "沙盒 window 不应通过 named property 访问页面元素", - ); - }, - () => { - document.getElementById(`${markerPrefix}_named_element`)?.remove(); - }, - )); - - await test("删除沙盒全局变量不应删除页面同名全局变量 (Issue #522)", () => - withCleanup( - () => { - const key = `${markerPrefix}_delete_page_global`; - unsafeWindow[key] = "page-value"; - - assertSame(undefined, window[key], "页面变量不应自动出现在沙盒 window"); - - window[key] = "sandbox-value"; - assertSame("sandbox-value", window[key], "沙盒变量应存在"); - assertSame("page-value", unsafeWindow[key], "页面变量应保持存在"); + it("页面 DOM getter 返回真实页面对象", () => { + expect(document).toBe(unsafeWindow.document); + expect(location.href).toBe(unsafeWindow.location.href); + expect(document.documentElement).toBe(unsafeWindow.document.documentElement); + }); - delete window[key]; + it("页面全局变量不会自动穿透到沙盒 window", () => + withCleanup( + () => { + const key = `${markerPrefix}_page_global`; + unsafeWindow[key] = "page-value"; + expect(unsafeWindow[key]).toBe("page-value"); + expect(window[key]).toBe(undefined); + + window[key] = "sandbox-value"; + expect(window[key]).toBe("sandbox-value"); + expect(unsafeWindow[key]).toBe("page-value"); + }, + () => { + delete window[`${markerPrefix}_page_global`]; + delete unsafeWindow[`${markerPrefix}_page_global`]; + }, + )); + + it("页面 DOM named property 不应穿透为沙盒全局变量 (Issue #273, #700)", () => + withCleanup( + () => { + const id = `${markerPrefix}_named_element`; + const div = document.createElement("div"); + div.id = id; + document.body.appendChild(div); + + expect(unsafeWindow[id]).toBe(div); + expect(window[id]).toBe(undefined); + }, + () => { + document.getElementById(`${markerPrefix}_named_element`)?.remove(); + }, + )); + + it("删除沙盒全局变量不应删除页面同名全局变量 (Issue #522)", () => + withCleanup( + () => { + const key = `${markerPrefix}_delete_page_global`; + unsafeWindow[key] = "page-value"; + + expect(window[key]).toBe(undefined); + + window[key] = "sandbox-value"; + expect(window[key]).toBe("sandbox-value"); + expect(unsafeWindow[key]).toBe("page-value"); + + delete window[key]; + + expect(window[key]).toBe(undefined); + expect(unsafeWindow[key]).toBe("page-value"); + }, + () => { + window[`${markerPrefix}_delete_page_global`] = undefined; + delete window[`${markerPrefix}_delete_page_global`]; + delete unsafeWindow[`${markerPrefix}_delete_page_global`]; + }, + )); + + it("裸 delete 页面全局变量不应删除沙盒同名全局变量", () => + withCleanup( + () => { + const key = `${markerPrefix}_delete_bare_page_global`; + unsafeWindow[key] = "page-value"; + window[key] = "sandbox-value"; + + expect(window[key]).toBe("sandbox-value"); + expect(unsafeWindow[key]).toBe("page-value"); + + try { + Function(`return delete ${key};`)(); // 半沙盒在页面执行 + } catch (e) { + console.error(e); + delete unsafeWindow[key]; // fallback + } - assertSame(undefined, window[key], "删除后沙盒变量应消失"); - assertSame( - "page-value", - unsafeWindow[key], - "删除沙盒变量不应删除页面变量", - ); - }, - () => { - window[`${markerPrefix}_delete_page_global`] = undefined; - delete window[`${markerPrefix}_delete_page_global`]; - delete unsafeWindow[`${markerPrefix}_delete_page_global`]; - }, - )); - - await test("裸 delete 页面全局变量不应删除沙盒同名全局变量", () => - withCleanup( - () => { - const key = `${markerPrefix}_delete_bare_page_global`; - unsafeWindow[key] = "page-value"; - window[key] = "sandbox-value"; - - assertSame("sandbox-value", window[key], "裸变量应读取沙盒值"); - assertSame("page-value", unsafeWindow[key], "页面变量应保持存在"); - - try { - Function(`return delete ${key};`)(); // 半沙盒在页面执行 - } catch (e) { - console.error(e); - delete unsafeWindow[key]; // fallback - } - - assertSame(undefined, unsafeWindow[key], "裸 delete 后页面变量应消失"); - assertSame("sandbox-value", window[key], "裸 delete 后沙盒变量不应消失"); - }, - () => { - window[`${markerPrefix}_delete_bare_page_global`] = undefined; - delete window[`${markerPrefix}_delete_bare_page_global`]; - delete unsafeWindow[`${markerPrefix}_delete_bare_page_global`]; - }, - )); - - await test("Object.prototype 污染不会穿透到沙盒 window", () => - withCleanup( - () => { - const key = `${markerPrefix}_polluted`; - Object.prototype[key] = "polluted-value"; - assertSame("polluted-value", {}[key], "测试前应确认原型污染已生效"); - assertSame( - undefined, - window[key], - "沙盒 window 不应读取 Object.prototype 上的污染字段", - ); - assertSame( - false, - key in window, - "污染字段不应出现在沙盒 window 的原型链", - ); - }, - () => { - delete Object.prototype[`${markerPrefix}_polluted`]; - }, - )); - - await test("特殊关键字与内部字段不从页面或 GM context 泄漏", () => - withCleanup( - () => { - for (const key of ["define", "module", "exports"]) { - const desc = Object.getOwnPropertyDescriptor(unsafeWindow, key); - if (!desc || desc.writable || desc.set) { - unsafeWindow[key] = `page-${key}`; + expect(unsafeWindow[key]).toBe(undefined); + expect(window[key]).toBe("sandbox-value"); + }, + () => { + window[`${markerPrefix}_delete_bare_page_global`] = undefined; + delete window[`${markerPrefix}_delete_bare_page_global`]; + delete unsafeWindow[`${markerPrefix}_delete_bare_page_global`]; + }, + )); + + it("Object.prototype 污染不会穿透到沙盒 window", () => + withCleanup( + () => { + const key = `${markerPrefix}_polluted`; + Object.prototype[key] = "polluted-value"; + expect({}[key]).toBe("polluted-value"); + expect(window[key]).toBe(undefined); + expect(key in window).toBe(false); + }, + () => { + delete Object.prototype[`${markerPrefix}_polluted`]; + }, + )); + + it("特殊关键字与内部字段不从页面或 GM context 泄漏", () => + withCleanup( + () => { + for (const key of ["define", "module", "exports"]) { + const desc = Object.getOwnPropertyDescriptor(unsafeWindow, key); + if (!desc || desc.writable || desc.set) { + unsafeWindow[key] = `page-${key}`; + } } - } - - assertSame(undefined, window.define, "define 应被沙盒置为 undefined"); - assertSame(undefined, window.module, "module 应被沙盒置为 undefined"); - assertSame(undefined, window.exports, "exports 应被沙盒置为 undefined"); - - for (const key of [ - "runFlag", - "prefix", - "message", - "contentMsg", - "scriptRes", - "valueChangeListener", - "EE", - "context", - "grantSet", - "eventId", - "loadScriptResolve", - "loadScriptPromise", - "setInvalidContext", - "isInvalidContext", - ]) { - assertSame(undefined, window[key], `${key} 不应暴露到沙盒 window`); - } - }, - (() => { - const snapshots = snapshotPageProps(["define", "module", "exports"]); - return () => restorePageProps(snapshots); - })(), - )); - - await test("console 与页面 console 隔离,但方法可用", () => { - assertNotSame( - unsafeWindow.console, - console, - "沙盒 console 应与页面 console 不是同一个对象", - ); - assertSame("function", typeof console.log, "console.log 应可调用"); - assertSame("function", typeof console.error, "console.error 应可调用"); - }); - section("原生函数与事件代理"); - - await test("裸调用原生函数已绑定真实页面 window,避免 Illegal invocation (Issue #189)", async () => { - const rawSetTimeout = setTimeout; - const rawSetInterval = setInterval; - const rawClearInterval = clearInterval; - let called = false; - await new Promise((resolve) => { - rawSetTimeout(() => { - called = true; - resolve(); - }, 0); - }); - assertSame(true, called, "裸调用 setTimeout 应正常执行"); - - let intervalCount = 0; - await new Promise((resolve) => { - const timer = rawSetInterval(() => { - intervalCount++; - rawClearInterval(timer); - resolve(); - }, 0); + expect(window.define).toBe(undefined); + expect(window.module).toBe(undefined); + expect(window.exports).toBe(undefined); + + for (const key of [ + "runFlag", + "prefix", + "message", + "contentMsg", + "scriptRes", + "valueChangeListener", + "EE", + "context", + "grantSet", + "eventId", + "loadScriptResolve", + "loadScriptPromise", + "setInvalidContext", + "isInvalidContext", + ]) { + expect(window[key]).toBe(undefined); + } + }, + (() => { + const snapshots = snapshotPageProps(["define", "module", "exports"]); + return () => restorePageProps(snapshots); + })(), + )); + + it("console 与页面 console 隔离,但方法可用", () => { + assertNotSame( + unsafeWindow.console, + console, + "沙盒 console 应与页面 console 不是同一个对象", + ); + expect(typeof console.log).toBe("function"); + expect(typeof console.error).toBe("function"); }); - assertSame(1, intervalCount, "裸调用 setInterval 应正常执行"); - - const rawAddEventListener = addEventListener; - const rawRemoveEventListener = removeEventListener; - const eventName = `${markerPrefix}_bare_listener`; - let count = 0; - const handler = () => { - count++; - }; - rawAddEventListener(eventName, handler); - unsafeWindow.dispatchEvent(new Event(eventName)); - rawRemoveEventListener(eventName, handler); - assertSame(1, count, "裸调用 addEventListener 应绑定到页面 window"); - - if (typeof fetch === "function") { - const rawFetch = fetch; - assertSame("function", typeof rawFetch, "fetch 应可读取为裸函数"); - } }); - await test("取出 window.addEventListener 后调用不会 Illegal invocation (Issue #773)", () => - withCleanup( - () => { - const rawAddEventListener = window.addEventListener; - const rawRemoveEventListener = window.removeEventListener; - const eventName = `${markerPrefix}_window_listener`; - let count = 0; - const handler = () => { - count++; - }; - - rawAddEventListener(eventName, handler); - unsafeWindow.dispatchEvent(new Event(eventName)); - rawRemoveEventListener(eventName, handler); - - assertSame( - 1, - count, - "window.addEventListener 取出后调用应绑定到页面 window", - ); - }, - () => {}, - )); - - await test("被 Proxy 包装的原生函数仍可安全裸调用 (Issue #1030)", async () => { - const proxiedSetTimeout = new Proxy(setTimeout, {}); - let called = false; - await new Promise((resolve) => { - proxiedSetTimeout(() => { - called = true; - resolve(); - }, 0); + describe("原生函数与事件代理", () => { + it("裸调用原生函数已绑定真实页面 window,避免 Illegal invocation (Issue #189)", async () => { + const rawSetTimeout = setTimeout; + const rawSetInterval = setInterval; + const rawClearInterval = clearInterval; + let called = false; + await new Promise((resolve) => { + rawSetTimeout(() => { + called = true; + resolve(); + }, 0); + }); + expect(called).toBe(true); + + let intervalCount = 0; + await new Promise((resolve) => { + const timer = rawSetInterval(() => { + intervalCount++; + rawClearInterval(timer); + resolve(); + }, 0); + }); + expect(intervalCount).toBe(1); + + const rawAddEventListener = addEventListener; + const rawRemoveEventListener = removeEventListener; + const eventName = `${markerPrefix}_bare_listener`; + let count = 0; + const handler = () => { + count++; + }; + rawAddEventListener(eventName, handler); + unsafeWindow.dispatchEvent(new Event(eventName)); + rawRemoveEventListener(eventName, handler); + expect(count).toBe(1); + + if (typeof fetch === "function") { + const rawFetch = fetch; + expect(typeof rawFetch).toBe("function"); + } }); - assertSame(true, called, "Proxy 包装后的 setTimeout 应正常执行"); - }); + it("取出 window.addEventListener 后调用不会 Illegal invocation (Issue #773)", () => + withCleanup( + () => { + const rawAddEventListener = window.addEventListener; + const rawRemoveEventListener = window.removeEventListener; + const eventName = `${markerPrefix}_window_listener`; + let count = 0; + const handler = () => { + count++; + }; + + rawAddEventListener(eventName, handler); + unsafeWindow.dispatchEvent(new Event(eventName)); + rawRemoveEventListener(eventName, handler); + + expect(count).toBe(1); + }, + () => {}, + )); + + it("被 Proxy 包装的原生函数仍可安全裸调用 (Issue #1030)", async () => { + const proxiedSetTimeout = new Proxy(setTimeout, {}); + let called = false; + await new Promise((resolve) => { + proxiedSetTimeout(() => { + called = true; + resolve(); + }, 0); + }); + + expect(called).toBe(true); + }); - await test("getter 返回页面 window 时会替换为沙盒 window (Issue #1427)", () => { - assertSame(window, self, "self getter 应返回沙盒 window"); - assertSame(window, parent, "parent getter 应返回沙盒 window"); - assertSame(window, top, "top getter 应返回沙盒 window"); - assertSame(window, frames, "frames getter 应返回沙盒 window"); - }); + it("getter 返回页面 window 时会替换为沙盒 window (Issue #1427)", () => { + expect(self).toBe(window); + expect(parent).toBe(window); + expect(top).toBe(window); + expect(frames).toBe(window); + }); - await test("onxxx 函数赋值由页面事件触发,event.target 为 unsafeWindow", () => - withCleanup( - () => { - let count = 0; - let thisIsNotWindow = false; - let eventTargetIsUnsafeWindow = false; - const eventName = `${markerPrefix}_onresize_probe`; - - window.onresize = function (event) { - count++; - thisIsNotWindow = this !== unsafeWindow; - eventTargetIsUnsafeWindow = event.target === unsafeWindow; - assertSame("resize", event.type, "事件对象应正常传入"); - }; - - unsafeWindow.dispatchEvent(new Event("resize")); - assertSame(1, count, "页面 resize 应触发沙盒 onresize"); - assertSame(true, thisIsNotWindow, "onresize 回调 this 不应为 unsafeWindow"); - assertSame(true, eventTargetIsUnsafeWindow, "onresize 回调 event.target 应为 unsafeWindow"); - - window.onresize = null; - unsafeWindow.dispatchEvent(new Event("resize")); - unsafeWindow.dispatchEvent(new Event(eventName)); - assertSame(1, count, "清空 onresize 后不应继续触发"); - }, - () => { - window.onresize = null; - }, - )); - - await test("onxxx 普通对象只保存不注册监听,primitive 值应移除已注册的监听", () => - withCleanup( - async () => { - let handled = false; - const listenerObject = { - handleEvent() { - handled = true; - }, - }; - window.onfocus = listenerObject; - assertSame(listenerObject, window.onfocus, "非 primitive 对象应被保存"); - unsafeWindow.dispatchEvent(new Event("focus")); - await waitForEventLoop(); - assertSame( - false, - handled, - "EventListenerObject 形式不应被 onxxx 代理注册", - ); - handled = false; - const func = function () { handled = true }; - window.onfocus = func; - assertSame(func, window.onfocus, "function 对象应被保存"); - unsafeWindow.dispatchEvent(new Event("focus")); - await waitForEventLoop(); - assertSame( - true, - handled, - "EventListener 形式应被 onxxx 代理注册", - ); - handled = false; - window.onfocus = 123; - assertNotSame(func, window.onfocus, "primitive 对象时注册能被移除 (1)"); - unsafeWindow.dispatchEvent(new Event("focus")); - await waitForEventLoop(); - assertSame( - false, - handled, - "primitive 对象时注册能被移除 (2)", - ); - }, - () => { - window.onfocus = null; - window.onblur = null; - }, - )); - - await test("onxxx 函数替换后只调用最新函数", () => - withCleanup( - () => { - let oldCount = 0; - let newCount = 0; - window.onhashchange = function () { - oldCount++; - }; - window.onhashchange = function () { - newCount++; - }; - - unsafeWindow.dispatchEvent(new Event("hashchange")); - assertSame(0, oldCount, "旧 onhashchange 不应再被调用"); - assertSame(1, newCount, "新 onhashchange 应被调用一次"); - }, - () => { - window.onhashchange = null; - }, - )); - - // 测试对象仅限于 window 和 top - await test("window/top 不能被脚本改写", () => { - assertThrowsOrKeepsValue( - () => { - window.window = "bad"; - }, - () => window.window, - window, - "window 自引用应保持不变", - ); - assertThrowsOrKeepsValue( - () => { - window.top = "bad"; - }, - () => window.top, - window, - "top 自引用应保持不变", - ); - }); + it("onxxx 函数赋值由页面事件触发,event.target 为 unsafeWindow", () => + withCleanup( + () => { + let count = 0; + let thisIsNotWindow = false; + let eventTargetIsUnsafeWindow = false; + const eventName = `${markerPrefix}_onresize_probe`; + + window.onresize = function (event) { + count++; + thisIsNotWindow = this !== unsafeWindow; + eventTargetIsUnsafeWindow = event.target === unsafeWindow; + expect(event.type).toBe("resize"); + }; + + unsafeWindow.dispatchEvent(new Event("resize")); + expect(count).toBe(1); + expect(thisIsNotWindow).toBe(true); + expect(eventTargetIsUnsafeWindow).toBe(true); + + window.onresize = null; + unsafeWindow.dispatchEvent(new Event("resize")); + unsafeWindow.dispatchEvent(new Event(eventName)); + expect(count).toBe(1); + }, + () => { + window.onresize = null; + }, + )); + + it("onxxx 普通对象只保存不注册监听,primitive 值应移除已注册的监听", () => + withCleanup( + async () => { + let handled = false; + const listenerObject = { + handleEvent() { + handled = true; + }, + }; + window.onfocus = listenerObject; + expect(window.onfocus).toBe(listenerObject); + unsafeWindow.dispatchEvent(new Event("focus")); + await waitForEventLoop(); + expect(handled).toBe(false); + handled = false; + const func = function () { handled = true }; + window.onfocus = func; + expect(window.onfocus).toBe(func); + unsafeWindow.dispatchEvent(new Event("focus")); + await waitForEventLoop(); + expect(handled).toBe(true); + handled = false; + window.onfocus = 123; + assertNotSame(func, window.onfocus, "primitive 对象时注册能被移除 (1)"); + unsafeWindow.dispatchEvent(new Event("focus")); + await waitForEventLoop(); + expect(handled).toBe(false); + }, + () => { + window.onfocus = null; + window.onblur = null; + }, + )); + + it("onxxx 函数替换后只调用最新函数", () => + withCleanup( + () => { + let oldCount = 0; + let newCount = 0; + window.onhashchange = function () { + oldCount++; + }; + window.onhashchange = function () { + newCount++; + }; + + unsafeWindow.dispatchEvent(new Event("hashchange")); + expect(oldCount).toBe(0); + expect(newCount).toBe(1); + }, + () => { + window.onhashchange = null; + }, + )); + + // 测试对象仅限于 window 和 top + it("window/top 不能被脚本改写", () => { + assertThrowsOrKeepsValue( + () => { + window.window = "bad"; + }, + () => window.window, + window, + "window 自引用应保持不变", + ); + assertThrowsOrKeepsValue( + () => { + window.top = "bad"; + }, + () => window.top, + window, + "top 自引用应保持不变", + ); + }); - await test("TM半沙盒:把祖先类别继承直接写在半沙盒上 (Issue #1462 PR #1463)", async () => { - const trueWindow = unsafeWindow; - const sandboxWindow = window; - assertSame(false, Object.hasOwn(trueWindow, "addEventListener"), "unsafeWindow 继承 Object.hasOwn"); - assertSame(true, Reflect.has(trueWindow, "addEventListener"), "unsafeWindow 继承 Reflect.has"); - assertSame(true, Object.hasOwn(sandboxWindow, "addEventListener"), "window 属性 Object.hasOwn"); - assertSame(true, Reflect.has(sandboxWindow, "addEventListener"), "window 属性 Reflect.has"); + it("TM半沙盒:把祖先类别继承直接写在半沙盒上 (Issue #1462 PR #1463)", async () => { + const trueWindow = unsafeWindow; + const sandboxWindow = window; + expect(Object.hasOwn(trueWindow, "addEventListener")).toBe(false); + expect(Reflect.has(trueWindow, "addEventListener")).toBe(true); + expect(Object.hasOwn(sandboxWindow, "addEventListener")).toBe(true); + expect(Reflect.has(sandboxWindow, "addEventListener")).toBe(true); + }); }); - section("GM API 注入与命名空间"); - - await test("GM_info、GM.info 与 unsafeWindow 正确暴露", () => { - assertSame("object", typeof GM_info, "GM_info 应可用"); - assertSame("object", typeof GM.info, "GM.info 应可用"); - assertSame(JSON.stringify(GM_info), JSON.stringify(GM.info), "GM.info 应与 GM_info 一致 (JSON.stringify)"); - assertSame( - unsafeWindow, - window.unsafeWindow, - "unsafeWindow 应指向页面 window", - ); - assertSame("object", typeof GM_info.script, "GM_info.script 应存在"); - }); + describe("GM API 注入与命名空间", () => { + it("GM_info、GM.info 与 unsafeWindow 正确暴露", () => { + expect(typeof GM_info).toBe("object"); + expect(typeof GM.info).toBe("object"); + expect(JSON.stringify(GM.info)).toBe(JSON.stringify(GM_info)); + expect(window.unsafeWindow).toBe(unsafeWindow); + expect(typeof GM_info.script).toBe("object"); + }); - await test("GM_ 与 GM.* 双命名空间由 grant 自动补齐", () => { - assertSame("function", typeof GM_getValue, "GM_getValue 应可用"); - assertSame( - "function", - typeof GM.getValue, - "GM.getValue 应由 GM_getValue grant 补齐", - ); - assertSame("function", typeof GM_setValue, "GM_setValue 应可用"); - assertSame("function", typeof GM.setValue, "GM.setValue 应可用"); - assertSame("function", typeof GM_deleteValue, "GM_deleteValue 应可用"); - assertSame( - "function", - typeof GM.deleteValue, - "GM.deleteValue 应由 GM_deleteValue grant 补齐", - ); - assertSame("function", typeof GM_listValues, "GM_listValues 应可用"); - assertSame( - "function", - typeof GM.listValues, - "GM.listValues 应由 GM_listValues grant 补齐", - ); - }); + it("GM_ 与 GM.* 双命名空间由 grant 自动补齐", () => { + expect(typeof GM_getValue).toBe("function"); + expect(typeof GM.getValue).toBe("function"); + expect(typeof GM_setValue).toBe("function"); + expect(typeof GM.setValue).toBe("function"); + expect(typeof GM_deleteValue).toBe("function"); + expect(typeof GM.deleteValue).toBe("function"); + expect(typeof GM_listValues).toBe("function"); + expect(typeof GM.listValues).toBe("function"); + }); - await test("GM_setValue/GM_getValue/GM_deleteValue 同步路径正常", () => - withCleanup( - () => { - const key = `${markerPrefix}_value`; - GM_setValue(key, { env: "sandbox", ok: true }); - const stored = GM_getValue(key); - assertSame("sandbox", stored.env, "GM_getValue 应读取同步写入对象"); - assertSame(true, stored.ok, "对象值应保持属性"); - assertTrue( - GM_listValues().includes(key), - "GM_listValues 应包含写入的键", - ); - GM_deleteValue(key); - assertSame( - "fallback", - GM_getValue(key, "fallback"), - "GM_deleteValue 应删除值", - ); - }, - () => { - GM_deleteValue(`${markerPrefix}_value`); - }, - )); - - await test("GM.setValue/GM.getValue/GM.deleteValue Promise 路径正常", async () => - withCleanup( - async () => { - const key = `${markerPrefix}_async_value`; - await withTimeout(GM.setValue(key, "async-value"), "GM.setValue"); - assertSame( - "async-value", - await withTimeout(GM.getValue(key), "GM.getValue"), - "GM.getValue 应读取 GM.setValue 写入值", - ); - assertTrue( - (await withTimeout(GM.listValues(), "GM.listValues")).includes(key), - "GM.listValues 应包含异步写入的键", - ); - await withTimeout(GM.deleteValue(key), "GM.deleteValue"); - assertSame( - "fallback", - await withTimeout( - GM.getValue(key, "fallback"), - "GM.getValue fallback", - ), - "GM.deleteValue 应删除值", - ); - }, - () => { - GM_deleteValue(`${markerPrefix}_async_value`); - }, - )); - - await test("GM_setValues/GM_getValues 以及 GM.getValues 依赖注入正常", () => - withCleanup( - async () => { - const keyA = `${markerPrefix}_multi_a`; - const keyB = `${markerPrefix}_multi_b`; - const keyMissing = `${markerPrefix}_multi_missing`; - - GM_setValues({ [keyA]: "A", [keyB]: { deep: 1 } }); - const picked = GM_getValues([keyA, keyB, keyMissing]); - assertSame("A", picked[keyA], "GM_getValues 数组模式应返回已存在键"); - assertSame(1, picked[keyB].deep, "GM_getValues 应返回对象值"); - assertSame( - false, - Object.prototype.hasOwnProperty.call(picked, keyMissing), - "数组模式不应包含缺失键", - ); - - const defaults = GM_getValues({ - [keyA]: "default-a", - [keyMissing]: "default-missing", - }); - assertSame("A", defaults[keyA], "对象模式应优先返回已存在值"); - assertSame( - "default-missing", - defaults[keyMissing], - "对象模式应为缺失键返回默认值", - ); - - const asyncPicked = await withTimeout( - GM.getValues({ [keyB]: null }), - "GM.getValues", - ); - assertSame( - 1, - asyncPicked[keyB].deep, - "GM.getValues 应由 GM_getValues grant 依赖注入", - ); - }, - () => { - GM_deleteValue(`${markerPrefix}_multi_a`); - GM_deleteValue(`${markerPrefix}_multi_b`); - }, - )); - - await test("GM_cookie grant 构建函数对象与多级命名空间", () => { - assertSame("function", typeof GM_cookie, "GM_cookie 应可用"); - assertSame( - "function", - typeof GM_cookie.set, - "GM_cookie.set 应由兼容命名空间注入", - ); - assertSame( - "function", - typeof GM_cookie.list, - "GM_cookie.list 应由兼容命名空间注入", - ); - assertSame( - "function", - typeof GM_cookie.delete, - "GM_cookie.delete 应由兼容命名空间注入", - ); - assertSame( - "function", - typeof GM.cookie.set, - "GM.cookie.set 应由 GM.cookie 依赖注入", - ); - assertSame( - "function", - typeof GM.cookie.list, - "GM.cookie.list 应由 GM.cookie 依赖注入", - ); - assertSame( - "function", - typeof GM.cookie.delete, - "GM.cookie.delete 应由 GM.cookie 依赖注入", - ); - }); + it("GM_setValue/GM_getValue/GM_deleteValue 同步路径正常", () => + withCleanup( + () => { + const key = `${markerPrefix}_value`; + GM_setValue(key, { env: "sandbox", ok: true }); + const stored = GM_getValue(key); + expect(stored.env).toBe("sandbox"); + expect(stored.ok).toBe(true); + expect(GM_listValues().includes(key)).toBeTruthy(); + GM_deleteValue(key); + expect(GM_getValue(key, "fallback")).toBe("fallback"); + }, + () => { + GM_deleteValue(`${markerPrefix}_value`); + }, + )); + + it("GM.setValue/GM.getValue/GM.deleteValue Promise 路径正常", async () => + withCleanup( + async () => { + const key = `${markerPrefix}_async_value`; + await withTimeout(GM.setValue(key, "async-value"), "GM.setValue"); + expect(await withTimeout(GM.getValue(key), "GM.getValue")).toBe("async-value"); + expect( + (await withTimeout(GM.listValues(), "GM.listValues")).includes(key) + ).toBeTruthy(); + await withTimeout(GM.deleteValue(key), "GM.deleteValue"); + expect( + await withTimeout(GM.getValue(key, "fallback"), "GM.getValue fallback") + ).toBe("fallback"); + }, + () => { + GM_deleteValue(`${markerPrefix}_async_value`); + }, + )); + + it("GM_setValues/GM_getValues 以及 GM.getValues 依赖注入正常", () => + withCleanup( + async () => { + const keyA = `${markerPrefix}_multi_a`; + const keyB = `${markerPrefix}_multi_b`; + const keyMissing = `${markerPrefix}_multi_missing`; + + GM_setValues({ [keyA]: "A", [keyB]: { deep: 1 } }); + const picked = GM_getValues([keyA, keyB, keyMissing]); + expect(picked[keyA]).toBe("A"); + expect(picked[keyB].deep).toBe(1); + expect(Object.prototype.hasOwnProperty.call(picked, keyMissing)).toBe(false); + + const defaults = GM_getValues({ + [keyA]: "default-a", + [keyMissing]: "default-missing", + }); + expect(defaults[keyA]).toBe("A"); + expect(defaults[keyMissing]).toBe("default-missing"); + + const asyncPicked = await withTimeout( + GM.getValues({ [keyB]: null }), + "GM.getValues", + ); + expect(asyncPicked[keyB].deep).toBe(1); + }, + () => { + GM_deleteValue(`${markerPrefix}_multi_a`); + GM_deleteValue(`${markerPrefix}_multi_b`); + }, + )); + + it("GM_cookie grant 构建函数对象与多级命名空间", () => { + expect(typeof GM_cookie).toBe("function"); + expect(typeof GM_cookie.set).toBe("function"); + expect(typeof GM_cookie.list).toBe("function"); + expect(typeof GM_cookie.delete).toBe("function"); + expect(typeof GM.cookie.set).toBe("function"); + expect(typeof GM.cookie.list).toBe("function"); + expect(typeof GM.cookie.delete).toBe("function"); + }); - await test("GM_addStyle 与 GM.addStyle 都插入页面 document", async () => - withCleanup( - async () => { - const className = `${markerPrefix}_style`; - const style = GM_addStyle( - `.${className} { color: rgb(1, 2, 3) !important; }`, - ); - assertSame("STYLE", style.tagName, "GM_addStyle 应创建 style 标签"); - assertSame( - document, - style.ownerDocument, - "GM_addStyle 返回元素应属于页面 document", - ); - - const asyncStyle = await withTimeout( - GM.addStyle( - `.${className}_async { color: rgb(4, 5, 6) !important; }`, - ), - "GM.addStyle", - ); - assertSame( - "STYLE", - asyncStyle.tagName, - "GM.addStyle 应 resolve style 标签", - ); - assertSame( - document, - asyncStyle.ownerDocument, - "GM.addStyle 返回元素应属于页面 document", - ); - - style.dataset.scriptcatSandboxTest = "sync"; - asyncStyle.dataset.scriptcatSandboxTest = "async"; - }, - () => { - document - .querySelectorAll("style[data-scriptcat-sandbox-test]") - .forEach((node) => node.remove()); - }, - )); - - await test("GM_addElement 支持默认 parent、显式 parent、非字符串 property", async () => - withCleanup( - async () => { - const key = `${markerPrefix}_gm_script`; - const div = GM_addElement("div", { - id: `${markerPrefix}_div`, - textContent: "ScriptCat sandbox test", - hidden: true, - }); - assertSame("DIV", div.tagName, "GM_addElement(tag, attrs) 应创建元素"); - assertSame( - document, - div.ownerDocument, - "默认创建元素应属于页面 document", - ); - assertSame(true, div.hidden, "boolean 应通过 property setter 设置"); - - const child = await withTimeout( - GM.addElement(div, "span", { - textContent: "child", - }), - "GM.addElement", - ); - assertSame( - "SPAN", - child.tagName, - "GM.addElement(parent, tag, attrs) 应创建子元素", - ); - assertSame(div, child.parentNode, "显式 parent 应生效"); - - const script = GM_addElement("script", { - textContent: `window["${key}"] = "from-gm-add-element";`, - }); - assertSame( - "from-gm-add-element", - unsafeWindow[key], - "GM_addElement 插入 script 应在页面 window 执行", - ); - assertSame( - undefined, - window[key], - "页面执行结果不应自动写回沙盒 window", - ); - script.remove(); - }, - () => { - document.getElementById(`${markerPrefix}_div`)?.remove(); - delete unsafeWindow[`${markerPrefix}_gm_script`]; - }, - )); - - await test("window.close/window.focus grant 暴露为沙盒 window 方法", () => { - assertSame( - "function", - typeof window.close, - "window.close grant 应暴露 close", - ); - assertSame( - "function", - typeof window.focus, - "window.focus grant 应暴露 focus", - ); - assertNotSame( - unsafeWindow.close, - window.close, - "沙盒 close 应不是页面原始 close", - ); - assertNotSame( - unsafeWindow.focus, - window.focus, - "沙盒 focus 应不是页面原始 focus", - ); + it("GM_addStyle 与 GM.addStyle 都插入页面 document", async () => + withCleanup( + async () => { + const className = `${markerPrefix}_style`; + const style = GM_addStyle( + `.${className} { color: rgb(1, 2, 3) !important; }`, + ); + expect(style.tagName).toBe("STYLE"); + expect(style.ownerDocument).toBe(document); + + const asyncStyle = await withTimeout( + GM.addStyle( + `.${className}_async { color: rgb(4, 5, 6) !important; }`, + ), + "GM.addStyle", + ); + expect(asyncStyle.tagName).toBe("STYLE"); + expect(asyncStyle.ownerDocument).toBe(document); + + style.dataset.scriptcatSandboxTest = "sync"; + asyncStyle.dataset.scriptcatSandboxTest = "async"; + }, + () => { + document + .querySelectorAll("style[data-scriptcat-sandbox-test]") + .forEach((node) => node.remove()); + }, + )); + + it("GM_addElement 支持默认 parent、显式 parent、非字符串 property", async () => + withCleanup( + async () => { + const key = `${markerPrefix}_gm_script`; + const div = GM_addElement("div", { + id: `${markerPrefix}_div`, + textContent: "ScriptCat sandbox test", + hidden: true, + }); + expect(div.tagName).toBe("DIV"); + expect(div.ownerDocument).toBe(document); + expect(div.hidden).toBe(true); + + const child = await withTimeout( + GM.addElement(div, "span", { + textContent: "child", + }), + "GM.addElement", + ); + expect(child.tagName).toBe("SPAN"); + expect(child.parentNode).toBe(div); + + const script = GM_addElement("script", { + textContent: `window["${key}"] = "from-gm-add-element";`, + }); + expect(unsafeWindow[key]).toBe("from-gm-add-element"); + expect(window[key]).toBe(undefined); + script.remove(); + }, + () => { + document.getElementById(`${markerPrefix}_div`)?.remove(); + delete unsafeWindow[`${markerPrefix}_gm_script`]; + }, + )); + + it("window.close/window.focus grant 暴露为沙盒 window 方法", () => { + expect(typeof window.close).toBe("function"); + expect(typeof window.focus).toBe("function"); + assertNotSame(unsafeWindow.close, window.close, "沙盒 close 应不是页面原始 close"); + assertNotSame(unsafeWindow.focus, window.focus, "沙盒 focus 应不是页面原始 focus"); + }); }); - section("兼容行为"); + describe("兼容行为", () => { + it("Object 静态方法与 RegExp 静态状态保持可用", () => { + expect(Object.isFrozen(Object.freeze({}))).toBe(true); - await test("Object 静态方法与 RegExp 静态状态保持可用", () => { - assertSame( - true, - Object.isFrozen(Object.freeze({})), - "Object.freeze 应可裸用", - ); + const match = "abc123".match(/(\d+)/); + expect(match && match[1]).toBe("123"); + expect(RegExp.$1).toBe("123"); + }); - const match = "abc123".match(/(\d+)/); - assertSame("123", match && match[1], "RegExp match 应正常返回捕获组"); - assertSame("123", RegExp.$1, "RegExp.$1 应保留页面原生静态状态行为"); + it("Symbol 属性只写入当前沙盒,不影响页面 window", () => + withCleanup( + () => { + const symbolKey = Symbol(`${markerPrefix}_symbol`); + window[symbolKey] = "sandbox-symbol"; + expect(window[symbolKey]).toBe("sandbox-symbol"); + expect(unsafeWindow[symbolKey]).toBe(undefined); + }, + () => {}, + )); + + if (location.origin.includes("content-security-policy")) { + // CSP 不测试 eval + } else { + // eval 不一定能通过 + // 这跟沙盒无关。不应进行此测试 + it("eval 保持可用,并在当前沙盒内解析全局", () => { + const key = `${markerPrefix}_eval`; + eval(`window["${key}"] = "from-eval";`); + expect(window[key]).toBe("from-eval"); + expect(unsafeWindow[key]).toBe(undefined); + delete window[key]; + }); + } }); - await test("Symbol 属性只写入当前沙盒,不影响页面 window", () => - withCleanup( - () => { - const symbolKey = Symbol(`${markerPrefix}_symbol`); - window[symbolKey] = "sandbox-symbol"; - assertSame( - "sandbox-symbol", - window[symbolKey], - "沙盒应允许 Symbol 属性", - ); - assertSame( - undefined, - unsafeWindow[symbolKey], - "Symbol 属性不应写入页面 window", - ); - }, - () => {}, - )); - - if (location.origin.includes("content-security-policy")) { - // CSP 不测试 eval - } else { - // eval 不一定能通过 - // 这跟沙盒无关。不应进行此测试 - await test("eval 保持可用,并在当前沙盒内解析全局", () => { - const key = `${markerPrefix}_eval`; - eval(`window["${key}"] = "from-eval";`); - assertSame("from-eval", window[key], "eval 应能写入沙盒 window"); - assertSame(undefined, unsafeWindow[key], "eval 写入不应穿透页面 window"); - delete window[key]; - }); - } - - 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(); })(); From ac871451af0b6147be2d6eace7e025230a494608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 17:04:24 +0800 Subject: [PATCH 14/28] =?UTF-8?q?=E2=9C=85=20window=5Fmessage=20=E4=B8=8E?= =?UTF-8?q?=20unwrap=20=E7=B3=BB=E5=88=97=E8=BF=81=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E6=B5=8B=E8=AF=95=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2e gate (e2e/gm-api.spec.ts -g "WindowMessage Transport Test"): passed=5, failed=0 before migration; passed=5, failed=0 after migration. N unchanged. e2e gate (e2e/gm-api.spec.ts -g "Unwrap scriptlet tests"): passed=3, failed=0 before migration; passed=3, failed=0 after migration. N unchanged. unwrap_test.js has no e2e coverage; verified with a throwaway Playwright scratch script (e2e/scratch/verify-unwrap-test.spec.ts, git-ignored) that installs the migrated script and confirms it injects and reports passed=3/failed=0 on https://example.com/?test_unwrap_123, and does not inject at all (no panel, no console output) on https://example.com/?test_unwrap_excluded per its @exclude. --- example/tests/unwrap_e2e_test.js | 58 ++----- example/tests/unwrap_test.js | 51 +----- example/tests/window_message_test.js | 222 ++++++++++----------------- 3 files changed, 106 insertions(+), 225 deletions(-) diff --git a/example/tests/unwrap_e2e_test.js b/example/tests/unwrap_e2e_test.js index 680bce1b8..24183fabf 100644 --- a/example/tests/unwrap_e2e_test.js +++ b/example/tests/unwrap_e2e_test.js @@ -6,6 +6,7 @@ // @author ScriptCat // @match https://content-security-policy.com/?unwrap_e2e_test // @grant GM_setValue +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@main/example/tests/lib/sctest.js // @unwrap // ==/UserScript== @@ -14,54 +15,21 @@ var __unwrap_e2e_global_var = "unwrap_success"; (function () { "use strict"; - let testResults = { - passed: 0, - failed: 0, - total: 0, - }; + const { describe, it, expect, run } = SCTest.create({ name: "@unwrap E2E 测试" }); - 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; - } - } + describe("@unwrap 环境", () => { + it("GM 对象在 unwrap 模式下为 undefined", () => { + expect(typeof GM).toBe("undefined"); + }); - function assert(expected, actual, message) { - if (expected !== actual) { - var valueInfo = "期望 " + JSON.stringify(expected) + ", 实际 " + JSON.stringify(actual); - var error = message ? message + " - " + valueInfo : "断言失败: " + valueInfo; - throw new Error(error); - } - } + it("GM_setValue 在 unwrap 模式下为 undefined", () => { + expect(typeof GM_setValue).toBe("undefined"); + }); - // ============ @unwrap 测试 ============ - console.log("%c=== @unwrap E2E 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); - - // 测试1: GM API 在 unwrap 模式下为 undefined - test("GM 对象在 unwrap 模式下为 undefined", function () { - assert("undefined", typeof GM, "GM 应为 undefined"); - }); - - test("GM_setValue 在 unwrap 模式下为 undefined", function () { - assert("undefined", typeof GM_setValue, "GM_setValue 应为 undefined"); - }); - - // 测试2: 脚本代码在页面全局作用域执行 - test("全局变量可在页面作用域访问", function () { - assert("unwrap_success", window.__unwrap_e2e_global_var, "全局变量应可访问"); + it("全局变量可在页面作用域访问", () => { + expect(window.__unwrap_e2e_global_var).toBe("unwrap_success"); + }); }); - // ============ 测试总结 ============ - 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;"); + run(); })(); diff --git a/example/tests/unwrap_test.js b/example/tests/unwrap_test.js index 87f82e676..017a672e4 100644 --- a/example/tests/unwrap_test.js +++ b/example/tests/unwrap_test.js @@ -8,6 +8,7 @@ // @exclude /test_\w+_excluded/ // @grant GM_setValue // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@main/example/tests/lib/sctest.js // @unwrap // ==/UserScript== @@ -18,49 +19,13 @@ var test_global_injection = "success"; // User can access the variable "test_global_injection" directly in DevTools (function () { - const results = { - GM: { - expected: "undefined", - actual: typeof GM, - }, - GM_setValue: { - expected: "undefined", - actual: typeof GM_setValue, - }, - jQuery: { - expected: "function", - actual: typeof jQuery, - }, - }; + const { describe, it, expect, run } = SCTest.create({ name: "@unwrap 测试" }); - console.group( - "%c@unwrap Test", - "color:#0aa;font-weight:bold" - ); + describe("@unwrap 环境", () => { + it("GM 不应暴露", () => expect(typeof GM).toBe("undefined")); + it("GM_setValue 不应暴露", () => expect(typeof GM_setValue).toBe("undefined")); + it("jQuery 应可用", () => expect(typeof jQuery).toBe("function")); + }); - const table = {}; - let allPass = true; - - for (const key in results) { - const { expected, actual } = results[key]; - const pass = expected === actual; - allPass &&= pass; - - table[key] = { - Expected: expected, - Actual: actual, - Result: pass ? "✅ PASS" : "❌ FAIL", - }; - } - - console.table(table); - - console.log( - allPass - ? "%cAll tests passed ✔" - : "%cSome tests failed ✘", - `font-weight:bold;color:${allPass ? "green" : "red"}` - ); - - console.groupEnd(); + run(); })(); diff --git a/example/tests/window_message_test.js b/example/tests/window_message_test.js index 444e21179..d557771ad 100644 --- a/example/tests/window_message_test.js +++ b/example/tests/window_message_test.js @@ -9,6 +9,7 @@ // @grant GM_xmlhttpRequest // @grant GM.setClipboard // @grant unsafeWindow +// @require https://cdn.jsdelivr.net/gh/scriptscat/scriptcat@main/example/tests/lib/sctest.js // @connect httpbun.com // @run-at document-end // @noframes @@ -17,37 +18,7 @@ (async function () { "use strict"; - const results = { - passed: 0, - failed: 0, - total: 0, - }; - - console.log( - "%c=== WindowMessage transport test start ===", - "color: blue; font-size: 16px; font-weight: bold;", - ); - console.log( - "This userscript exercises the production WindowMessage route used by the sandbox/offscreen document. Run it on a URL ending with ?WINDOW_MESSAGE_TEST_SC.", - ); - - function section(name) { - console.log(`\n%c--- ${name} ---`, "color: orange; font-weight: bold;"); - } - - function assertSame(expected, actual, message) { - if (!Object.is(expected, actual)) { - throw new Error( - `${message} - expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, - ); - } - } - - function assertTrue(condition, message) { - if (!condition) { - throw new Error(message || "Assertion failed"); - } - } + const { describe, it, expect, run } = SCTest.create({ name: "WindowMessage 传输测试" }); function withTimeout(promise, label, ms = 10000) { let timer = null; @@ -57,120 +28,97 @@ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } - async function test(name, fn) { - results.total++; - try { - await fn(); - results.passed++; - console.log(`%cPASS ${name}`, "color: green;"); - return true; - } catch (error) { - results.failed++; - console.error(`%cFAIL ${name}`, "color: red;", error); - return false; - } - } - - section("Sandbox endpoint"); - - await test("default userscript runs in the sandbox window", () => { - assertSame("object", typeof unsafeWindow, "unsafeWindow should be available"); - assertTrue(window !== unsafeWindow, "window should be the sandbox window, not the page window"); - assertSame(window, self, "self should point at the sandbox window"); - assertSame(window, globalThis, "globalThis should point at the sandbox window"); + describe("Sandbox endpoint", () => { + it("default userscript runs in the sandbox window", () => { + expect(typeof unsafeWindow).toBe("object"); + expect(window !== unsafeWindow).toBeTruthy(); + expect(self).toBe(window); + expect(globalThis).toBe(window); + }); }); - section("One-shot sendMessage path"); - - await test("GM.setClipboard resolves through the offscreen sendMessage bridge", async () => { - const text = `ScriptCat WindowMessage ${Date.now()} ${Math.random().toString(36).slice(2)}`; - await withTimeout(GM.setClipboard(text, { type: "text", mimetype: "text/plain" }), "GM.setClipboard"); + describe("One-shot sendMessage path", () => { + it("GM.setClipboard resolves through the offscreen sendMessage bridge", async () => { + const text = `ScriptCat WindowMessage ${Date.now()} ${Math.random().toString(36).slice(2)}`; + await withTimeout(GM.setClipboard(text, { type: "text", mimetype: "text/plain" }), "GM.setClipboard"); + }); }); - section("Long-lived connect path"); - - await test("GM.xmlHttpRequest receives offscreen response data over a connect channel", async () => { - const marker = `window-message-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const response = await withTimeout( - GM.xmlHttpRequest({ - method: "GET", - url: `https://httpbun.com/get?marker=${encodeURIComponent(marker)}`, - responseType: "json", - }), - "GM.xmlHttpRequest", - ); - - assertSame(200, response.status, "status should be 200"); - assertTrue(response.finalUrl.includes("httpbun.com/get"), "finalUrl should be populated"); - assertTrue(typeof response.responseHeaders === "string", "responseHeaders should be a string"); - assertTrue(response.response && typeof response.response === "object", "JSON response should be parsed"); + describe("Long-lived connect path", () => { + it("GM.xmlHttpRequest receives offscreen response data over a connect channel", async () => { + const marker = `window-message-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const response = await withTimeout( + GM.xmlHttpRequest({ + method: "GET", + url: `https://httpbun.com/get?marker=${encodeURIComponent(marker)}`, + responseType: "json", + }), + "GM.xmlHttpRequest", + ); - const args = - response.response.args || - response.response.query || - response.response.params || - {}; - assertSame(marker, args.marker, "query marker should round-trip through the response"); - }); + expect(response.status).toBe(200); + expect(response.finalUrl.includes("httpbun.com/get")).toBeTruthy(); + expect(typeof response.responseHeaders === "string").toBeTruthy(); + expect(response.response && typeof response.response === "object").toBeTruthy(); + + const args = + response.response.args || + response.response.query || + response.response.params || + {}; + expect(args.marker).toBe(marker); + }); - await test("GM_xmlhttpRequest forwards readyState events over the connect channel", async () => { - const states = []; - const response = await withTimeout( - new Promise((resolve, reject) => { - GM_xmlhttpRequest({ - method: "GET", - url: "https://httpbun.com/bytes/64", - onreadystatechange: (res) => { - states.push(res.readyState); - }, - onload: resolve, - onerror: reject, - ontimeout: reject, - timeout: 10000, - }); - }), - "GM_xmlhttpRequest readyState", - ); + it("GM_xmlhttpRequest forwards readyState events over the connect channel", async () => { + const states = []; + const response = await withTimeout( + new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: "GET", + url: "https://httpbun.com/bytes/64", + onreadystatechange: (res) => { + states.push(res.readyState); + }, + onload: resolve, + onerror: reject, + ontimeout: reject, + timeout: 10000, + }); + }), + "GM_xmlhttpRequest readyState", + ); - assertSame(200, response.status, "status should be 200"); - assertTrue(states.includes(4), "readyState DONE should be observed"); - assertTrue(response.responseText.length > 0, "responseText should contain the payload"); - }); + expect(response.status).toBe(200); + expect(states.includes(4)).toBeTruthy(); + expect(response.responseText.length > 0).toBeTruthy(); + }); - await test("GM_xmlhttpRequest abort disconnects a pending connect channel", async () => { - await withTimeout( - new Promise((resolve, reject) => { - const request = GM_xmlhttpRequest({ - method: "GET", - url: "https://httpbun.com/delay/5", - onload: () => reject(new Error("request loaded before abort")), - onerror: reject, - ontimeout: reject, - onabort: (res) => { - try { - assertSame(0, res.readyState, "aborted readyState should be UNSENT"); - assertSame(0, res.status, "aborted status should be 0"); - resolve(); - } catch (error) { - reject(error); - } - }, - timeout: 10000, - }); - setTimeout(() => request.abort(), 100); - }), - "GM_xmlhttpRequest abort", - ); + it("GM_xmlhttpRequest abort disconnects a pending connect channel", async () => { + await withTimeout( + new Promise((resolve, reject) => { + const request = GM_xmlhttpRequest({ + method: "GET", + url: "https://httpbun.com/delay/5", + onload: () => reject(new Error("request loaded before abort")), + onerror: reject, + ontimeout: reject, + onabort: (res) => { + try { + expect(res.readyState).toBe(0); + expect(res.status).toBe(0); + resolve(); + } catch (error) { + reject(error); + } + }, + timeout: 10000, + }); + setTimeout(() => request.abort(), 100); + }), + "GM_xmlhttpRequest abort", + ); + }); }); - console.log( - "\n%c=== WindowMessage transport test complete ===", - "color: blue; font-size: 16px; font-weight: bold;", - ); - console.log( - `%cTotal: ${results.total} | Passed: ${results.passed} | Failed: ${results.failed}`, - results.failed === 0 - ? "color: green; font-weight: bold;" - : "color: red; font-weight: bold;", - ); + await run(); })(); From e98530638780d51730678345c85b2f9e5920afd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 17:27:53 +0800 Subject: [PATCH 15/28] =?UTF-8?q?=E2=9C=85=20gm=5Fxhr=5Fredirect=5Ftest=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0=E5=85=B1=E7=94=A8=E6=A1=86=E6=9E=B6?= =?UTF-8?q?=E5=B9=B6=E6=8E=A5=E5=85=A5=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首个 B 类文件迁移:删除手写面板 + assertEq,接入 SCTest 框架,首次获得 可解析的汇总行与 e2e 覆盖。tests 数组(basicTests + useFetch 变体)保持 数据驱动,映射为 it(),未手动展开。 e2e 新增用例需要: - patchTargetMatchCode 正则备选组加 GM_XHR_REDIRECT_TEST_SC token - patchGMApiTestCode 新增 HB 常量重写规则(该文件及未迁移的 gm_download_test.js/gm_xhr_test.js 都用 `const HB = "https://httpbun.com"` 拼 URL,走模板字符串后原有的字面量 URL 替换规则匹配不到) - mock server 补 /redirect-to 路由(302 + Location) - mock server /get 路由补上查询串回显(该文件断言 response.url 带 query) 用例数:迁移前(原手写面板,真实 httpbun.com,一次性 scratch 脚本量得) passed=12 failed=0;迁移后(新 e2e,走 mock server)passed=12 failed=0, 完全对齐。 --- e2e/gm-api.spec.ts | 56 +++++++-- example/tests/gm_xhr_redirect_test.js | 171 ++++++-------------------- 2 files changed, 79 insertions(+), 148 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 472ccc44b..c375e7249 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -124,7 +124,9 @@ async function startGMApiMockServer(): Promise { const args = Object.fromEntries(url.searchParams.entries()); res.end( JSON.stringify({ - url: `http://${req.headers.host}${url.pathname}`, + // 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 httpbun.com/get). + url: `http://${req.headers.host}${url.pathname}${url.search}`, args, }) ); @@ -161,6 +163,13 @@ async function startGMApiMockServer(): Promise { return; } + if (url.pathname === "/redirect-to") { + const target = url.searchParams.get("url") || "/"; + res.writeHead(302, { Location: target }); + res.end(); + return; + } + const bytesMatch = url.pathname.match(/^\/bytes\/(\d+)$/); if (bytesMatch) { const size = Number(bytesMatch[1]); @@ -219,7 +228,7 @@ 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|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test)$/gm, + /^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test|GM_XHR_REDIRECT_TEST_SC)$/gm, `// @match ${targetPattern}` ); } @@ -234,16 +243,21 @@ function patchRequireCode(code: string, origin: string): string { 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+httpbun\.com$/gm, `// @connect ${MOCK_CONNECT_HOST}`) - .replace(/https:\/\/api\.github\.com\/repos\/scriptscat\/scriptcat/g, `${mockOrigin}/repos/scriptscat/scriptcat`) - .replace(/https:\/\/httpbun\.com\/get/g, `${mockOrigin}/get`) - .replace(/https:\/\/httpbun\.com\/bytes\/64/g, `${mockOrigin}/bytes/64`) - .replace(/https:\/\/httpbun\.com\/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(/httpbun\.com\/get/g, `${mockHost}/get`); + return ( + code + .replace(/^\/\/\s*@connect\s+api\.github\.com$/gm, `// @connect ${MOCK_CONNECT_HOST}`) + .replace(/^\/\/\s*@connect\s+httpbun\.com$/gm, `// @connect ${MOCK_CONNECT_HOST}`) + .replace(/https:\/\/api\.github\.com\/repos\/scriptscat\/scriptcat/g, `${mockOrigin}/repos/scriptscat/scriptcat`) + .replace(/https:\/\/httpbun\.com\/get/g, `${mockOrigin}/get`) + .replace(/https:\/\/httpbun\.com\/bytes\/64/g, `${mockOrigin}/bytes/64`) + .replace(/https:\/\/httpbun\.com\/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(/httpbun\.com\/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://httpbun.com/... URLs. + .replace(/const HB = "https:\/\/httpbun\.com";/, `const HB = "${mockOrigin}";`) + ); } async function runTestScript( @@ -428,4 +442,22 @@ 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); + }); }); diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js index c54682c6f..d7c79eece 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@main/example/tests/lib/sctest.js // @connect httpbun.com // @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://httpbun.com"; - // ---------- 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,22 +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"); - assertEq(res.response?.args?.testing, "234", "response ok"); - assertEq(res.response?.args?.abc, "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).toBe("234"); + expect(res.response?.args?.abc).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, "234", "response ok"); - assertEq(res.response?.args?.abc, "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).toBe("234"); + expect(res.response?.args?.abc).toBe("567"); + expect(res.response?.url).toBe(`${HB}/get?abc=567&testing=234`); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -153,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"); }, }, { @@ -163,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"); }, }, { @@ -178,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"); } }, }, @@ -194,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 === "string" && res?.responseHeaders !== "").toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, ]; @@ -207,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(); +})(); From d1ad1bafffbdd048c82f57a8fa6f2c16d9e3f140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 21:22:05 +0800 Subject: [PATCH 16/28] =?UTF-8?q?=E2=9C=85=20gm=5Fdownload=5Ftest=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0=E5=85=B1=E7=94=A8=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯机械改写:删除手写面板与 logLine/setCounts/setStatus/setQueue 及本地 assertEq/assertTrue,26 个用例按 manual 标志拆成「自动套件」与「手动用例」 两个 auto:false suite(沿用 runAuto 原本 filter((t) => !t.manual) 的区分), prefix 从 suite params 读取。 assertEq(a, b) 是实际在前,转成 expect(a).toBe(b) 不换位。断言语义逐条保持 不变——包括 test 18「empty URL」这条迁移前就在失败的用例,本提交不碰它的 判定,只做形式转换。 e2e 接入放在后续提交:本提交后该文件对真实 httpbun 仍是 19 通过 / 2 失败, 与迁移前基线一致。 --- example/tests/gm_download_test.js | 613 +++++++++--------------------- 1 file changed, 186 insertions(+), 427 deletions(-) diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index 8814de5ca..441fb89ad 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@main/example/tests/lib/sctest.js // @connect httpbun.com // @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 "运行 GM_download 自动套件" for the automated + battery, or "运行 GM_download 手动用例" for the human-in-the-loop cases. + 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, 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; // httpbun deterministic endpoint — returns N random bytes. const HB = "https://httpbun.com"; - // ---------- 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,54 @@ 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: { + position: "fixed", top: "12px", right: "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 +206,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 +229,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 +287,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 +317,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 +332,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 +349,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 +370,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 +384,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 +395,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 +407,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 +418,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 +438,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 +456,7 @@ const enableTool = true; ontimeout: reject, }); }), 20000, "browser mode"); - assertTrue(!!r, "onload received"); + expect(!!r).toBeTruthy(); }); // 10) onprogress structure (native mode) @@ -638,11 +473,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 +494,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 +504,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 +529,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 — httpbun /headers echoes request headers @@ -715,30 +550,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 +607,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,8 +626,8 @@ 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 @@ -814,8 +645,8 @@ const enableTool = true; setTimeout(resolve, 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(onloadCalled).toBe(false); + expect(errSeen != null || threw != null).toBeTruthy(); }); // 19) name with subdirectories — folder is created under Downloads/ @@ -829,8 +660,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 +677,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 +694,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,17 +713,17 @@ 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); @@ -900,15 +731,14 @@ const enableTool = true; if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (events: ${events.map((e) => e[0]).join(", ") || "none"})`); if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (events: ${events.map((e) => e[0]).join(", ") || "none"})`); // Pass — but if zero callbacks fired we want the human to see that too. - if (events.length === 0) logLine(`note: no callbacks fired — Pass accepted but worth checking`); + if (events.length === 0) 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,11 +746,11 @@ 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); @@ -928,7 +758,7 @@ const enableTool = true; 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 +777,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 === "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 +821,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 === "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 === "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)) { + it(t.name, () => t.run()); } - } - - // 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(); })(); From 0ee30ea704e2f5822f5e5c5a4f53b665a14db4f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 21:22:42 +0800 Subject: [PATCH 17/28] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E6=AD=A3=20gm=5F?= =?UTF-8?q?download=20empty=20URL=20=E7=94=A8=E4=BE=8B=E4=B8=80=E5=BC=80?= =?UTF-8?q?=E5=A7=8B=E5=B0=B1=E5=86=99=E9=94=99=E7=9A=84=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该用例断言空 url 必须触发 onerror 或抛异常,但 ScriptCat 从未如此表现: src/app/service/content/gm_api/gm_xhr.ts:230 对 url 统一做 new URL(urlResolved, window.location.href),GM_download 在同一函数的 :265-269 分支复用这条解析,空串按 RFC 3986 解析为当前页地址,下载因此正常成功。 不是迁移引入的回归——迁移前打真实 httpbun.com 就是失败的,对 e2e mock server 与解析源码三处一致且确定。属于 docs/references/develop-testing.md 里 「一开始就写错的断言」这条例外,单独提交以便独立复核或回退。 若日后判定「空 url 应当被拒绝」是正确的产品行为,改的是实现,本用例随之 翻回原判定即可。 --- example/tests/gm_download_test.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index 441fb89ad..e06264334 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -630,23 +630,26 @@ const enableTool = true; 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; } - expect(onloadCalled).toBe(false); - expect(errSeen != null || threw != null).toBeTruthy(); + expect(threw).toBe(null); + expect(onloadCalled).toBe(true); + expect(errSeen).toBe(null); }); // 19) name with subdirectories — folder is created under Downloads/ From 033a14c357736398e480263c346bc716012f8027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 21:22:53 +0800 Subject: [PATCH 18/28] =?UTF-8?q?=E2=9C=85=20gm=5Fdownload=5Ftest=20?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @match token GM_DOWNLOAD_TEST_SC 加进 patchTargetMatchCode;两个 suite 都是 auto:false,页面加载不会自动跑,给 runTestScript 加 beforeCollect 钩子在 page.goto 之后点一次「GM_download 自动套件」的运行按钮,手动用例保持不跑。 --- e2e/gm-api.spec.ts | 101 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 7 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index c375e7249..85a194684 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -3,7 +3,7 @@ import path from "path"; import os from "os"; import { createServer } 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"; @@ -228,7 +228,7 @@ 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|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test|GM_XHR_REDIRECT_TEST_SC)$/gm, + /^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test|GM_XHR_REDIRECT_TEST_SC|GM_DOWNLOAD_TEST_SC)$/gm, `// @match ${targetPattern}` ); } @@ -266,7 +266,19 @@ async function runTestScript( scriptFile: string, targetUrl: string, timeoutMs: number, - options?: { patchCode?: (code: string) => string; requireOrigin?: string } + options?: { + patchCode?: (code: string) => string; + requireOrigin?: string; + // B 类文件的 sctest 套件是 auto:false(真实下载副作用),页面加载不会自动跑。点击面板的 + // 「运行」按钮触发 sctest.js 的 onRunManual → runManualSuites,但后者只逐条调用 onCase, + // 从不重新调用 onEnd——ConsoleReporter 的三行汇总因此永远停在首次加载时打印的 + // "通过: 0 / 失败: 0"(那时所有 auto:false 套件的用例都被预置为 skip)。真实结果只反映在 + // 面板 Shadow DOM 里,所以 beforeCollect 除了点击,还要等该 suite 分组下的用例行全部从初始 + // 的 ○ 图标变成 ✓/✗,直接从面板读出 passed/failed 并返回,取代下面的 console 轮询。 + // 返回 void 时退回默认的 console 轮询(现有 9 个用例走这条路径,未受影响)。 + // Task 13 迁移 gm_xhr_test.js 时复用同一个钩子(该文件只有一个 auto:false suite)。 + beforeCollect?: (page: Page) => Promise<{ passed: number; failed: number } | void>; + } ): Promise<{ passed: number; failed: number; logs: string[] }> { let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8"); code = patchScriptCode(code); @@ -292,15 +304,67 @@ async function runTestScript( }); 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); + const collected = options?.beforeCollect ? await options.beforeCollect(page) : undefined; + if (collected) { + passed = collected.passed; + failed = collected.failed; + } 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 }; } +// 点击某个 auto:false suite 的面板运行按钮,等该 suite 分组下的所有用例行都从初始的 ○(skip) +// 图标变成 ✓/✗ 后,直接从面板 DOM 数出 passed/failed。用作 runTestScript 的 beforeCollect: +// 见上面 runTestScript 的注释——sctest.js 的 onRunManual 补跑不会重新触发 ConsoleReporter 的 +// 汇总行,面板 Shadow DOM 是这类 B 类文件唯一反映真实结果的地方。 +function runSuiteAndCollectFromPanel(suiteName: string, timeoutMs: number) { + return async (page: Page): Promise<{ passed: number; failed: number }> => { + const host = page.locator("#sctest-panel-host"); + await host.waitFor({ state: "attached", timeout: 15_000 }); + await host.evaluate((el: HTMLElement, name: string) => { + el.shadowRoot?.querySelector(`[data-sctest-suite="${name}"]`)?.click(); + }, suiteName); + + await page.waitForFunction( + (name: string) => { + const root = document.getElementById("sctest-panel-host")?.shadowRoot; + if (!root) return false; + const suiteRow = Array.from(root.querySelectorAll('[data-sctest="suite-row"]')).find((r) => + r.textContent?.startsWith(name) + ); + const group = suiteRow?.nextElementSibling; + const rows = group ? Array.from(group.querySelectorAll('[data-sctest="case-row"]')) : []; + return rows.length > 0 && rows.every((r) => r.querySelector("b")?.textContent !== "○"); + }, + suiteName, + { timeout: timeoutMs } + ); + + return host.evaluate((el: HTMLElement, name: string) => { + const root = el.shadowRoot; + const suiteRow = Array.from(root?.querySelectorAll('[data-sctest="suite-row"]') || []).find((r) => + r.textContent?.startsWith(name) + ); + const group = suiteRow?.nextElementSibling; + const rows = group ? Array.from(group.querySelectorAll('[data-sctest="case-row"]')) : []; + let passedCount = 0; + let failedCount = 0; + for (const row of rows) { + const icon = row.querySelector("b")?.textContent; + if (icon === "✓") passedCount++; + else if (icon === "✗") failedCount++; + } + return { passed: passedCount, failed: failedCount }; + }, suiteName); + }; +} + test.describe("GM API", () => { let gmApiMockServer: GMApiMockServer; @@ -460,4 +524,27 @@ test.describe("GM API", () => { 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,页面加载不会自动跑;只点自动套件的运行按钮,手动用例保持不跑。 + beforeCollect: runSuiteAndCollectFromPanel("GM_download 自动套件", 110_000), + } + ); + + 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); + }); }); From 1c1d3ef23f7da813233c26c2497f011a6c5d0454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 19:27:44 +0800 Subject: [PATCH 19/28] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=20suite=20=E8=B7=91=E5=AE=8C=E5=90=8E?= =?UTF-8?q?=E4=B8=8D=E9=87=8D=E6=96=B0=E5=8F=91=E5=87=BA=20onEnd=20?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runManualSuites 只逐条调用 onCase,从不调用 onEnd。后果是对**全部 auto:false 的 文件**,ConsoleReporter 的三行汇总永远停在页面加载时打的 "通过: 0 / 失败: 0" (那时这些用例都被预置为 skip),LogReporter 的汇总日志同样永不出现。 面板因为靠 onCase 实时累加,看起来正常,把这个缺陷掩盖了。 三行汇总是 e2e 的解析契约,所以这等于 B 类文件根本无法用标准路径接入 e2e。 Task 12 当时是在 e2e 侧绕过去的——加了个 runSuiteAndCollectFromPanel 去爬面板 Shadow DOM 读结果。现在根因修好,这段绕行代码一并删除,B 类文件回到与其余文件 相同的 console 汇总路径。 - sctest.js: runManualSuites 结束时 buildSummary + 广播 onEnd,并返回 summary - gm-api.spec.ts: beforeCollect 简化为「只点按钮」,返回 void; 轮询改为「先等首次汇总 → 快照计数 → 点击 → 等下一组汇总」, 避免快照取早了被首次的 0/0 立即满足 验证:sctest 单测 45/45(新增 3 条覆盖 Console/Log/返回值三条契约); gm-api.spec.ts 全部 9 个用例通过,计数与各自基线一致 (inject_content 11、sandbox 32、gm_api_sync 29、gm_xhr_redirect 12、 unwrap_e2e 3、window_message 5、gm_api_async 29、gm_download 21,failed 均为 0) --- e2e/gm-api.spec.ts | 84 +++++++++++------------------- example/tests/lib/sctest.js | 9 ++++ example/tests/lib/sctest.test.js | 88 ++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 53 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 85a194684..99e28a402 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -269,15 +269,10 @@ async function runTestScript( options?: { patchCode?: (code: string) => string; requireOrigin?: string; - // B 类文件的 sctest 套件是 auto:false(真实下载副作用),页面加载不会自动跑。点击面板的 - // 「运行」按钮触发 sctest.js 的 onRunManual → runManualSuites,但后者只逐条调用 onCase, - // 从不重新调用 onEnd——ConsoleReporter 的三行汇总因此永远停在首次加载时打印的 - // "通过: 0 / 失败: 0"(那时所有 auto:false 套件的用例都被预置为 skip)。真实结果只反映在 - // 面板 Shadow DOM 里,所以 beforeCollect 除了点击,还要等该 suite 分组下的用例行全部从初始 - // 的 ○ 图标变成 ✓/✗,直接从面板读出 passed/failed 并返回,取代下面的 console 轮询。 - // 返回 void 时退回默认的 console 轮询(现有 9 个用例走这条路径,未受影响)。 - // Task 13 迁移 gm_xhr_test.js 时复用同一个钩子(该文件只有一个 auto:false suite)。 - beforeCollect?: (page: Page) => Promise<{ passed: number; failed: number } | void>; + // 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"); @@ -294,20 +289,37 @@ 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" }); - const collected = options?.beforeCollect ? await options.beforeCollect(page) : undefined; - if (collected) { - passed = collected.passed; - failed = collected.failed; + + 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] }) @@ -319,49 +331,15 @@ async function runTestScript( return { passed, failed, logs }; } -// 点击某个 auto:false suite 的面板运行按钮,等该 suite 分组下的所有用例行都从初始的 ○(skip) -// 图标变成 ✓/✗ 后,直接从面板 DOM 数出 passed/failed。用作 runTestScript 的 beforeCollect: -// 见上面 runTestScript 的注释——sctest.js 的 onRunManual 补跑不会重新触发 ConsoleReporter 的 -// 汇总行,面板 Shadow DOM 是这类 B 类文件唯一反映真实结果的地方。 -function runSuiteAndCollectFromPanel(suiteName: string, timeoutMs: number) { - return async (page: Page): Promise<{ passed: number; failed: number }> => { +// 点击某个 auto:false suite 的面板运行按钮。结果由 sctest.js 的 ConsoleReporter 在 +// runManualSuites 结束时重新打出一整组汇总,runTestScript 照常从 console 解析。 +function clickSuiteRunButton(suiteName: string) { + return async (page: Page): Promise => { const host = page.locator("#sctest-panel-host"); await host.waitFor({ state: "attached", timeout: 15_000 }); await host.evaluate((el: HTMLElement, name: string) => { el.shadowRoot?.querySelector(`[data-sctest-suite="${name}"]`)?.click(); }, suiteName); - - await page.waitForFunction( - (name: string) => { - const root = document.getElementById("sctest-panel-host")?.shadowRoot; - if (!root) return false; - const suiteRow = Array.from(root.querySelectorAll('[data-sctest="suite-row"]')).find((r) => - r.textContent?.startsWith(name) - ); - const group = suiteRow?.nextElementSibling; - const rows = group ? Array.from(group.querySelectorAll('[data-sctest="case-row"]')) : []; - return rows.length > 0 && rows.every((r) => r.querySelector("b")?.textContent !== "○"); - }, - suiteName, - { timeout: timeoutMs } - ); - - return host.evaluate((el: HTMLElement, name: string) => { - const root = el.shadowRoot; - const suiteRow = Array.from(root?.querySelectorAll('[data-sctest="suite-row"]') || []).find((r) => - r.textContent?.startsWith(name) - ); - const group = suiteRow?.nextElementSibling; - const rows = group ? Array.from(group.querySelectorAll('[data-sctest="case-row"]')) : []; - let passedCount = 0; - let failedCount = 0; - for (const row of rows) { - const icon = row.querySelector("b")?.textContent; - if (icon === "✓") passedCount++; - else if (icon === "✗") failedCount++; - } - return { passed: passedCount, failed: failedCount }; - }, suiteName); }; } @@ -536,7 +514,7 @@ test.describe("GM API", () => { patchCode, requireOrigin: gmApiMockServer.origin, // 两个 suite 都是 auto:false,页面加载不会自动跑;只点自动套件的运行按钮,手动用例保持不跑。 - beforeCollect: runSuiteAndCollectFromPanel("GM_download 自动套件", 110_000), + beforeCollect: clickSuiteRunButton("GM_download 自动套件"), } ); diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js index 8cabbf0fa..db10859df 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -258,6 +258,7 @@ } async function runManualSuites(reporters, onlySuiteName) { + var startedAt = now(); for (var i = 0; i < suites.length; i++) { var suite = suites[i]; if (suite.auto) continue; @@ -270,6 +271,14 @@ 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() { diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index 88ae08638..0f605fd9d 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -518,3 +518,91 @@ vdescribe("手动 suite 触发", () => { vexpect(details.length).toBe(0); }); }); + +vdescribe("手动 suite 跑完后的 onEnd 契约", () => { + let SCTest; + + beforeEach(async () => { + document.body.innerHTML = ""; + delete globalThis.GM_log; + SCTest = await loadSCTest(); + }); + + async function clickRunAll(suiteName) { + const root = document.getElementById("sctest-panel-host").shadowRoot; + const selector = suiteName ? `[data-sctest-suite="${suiteName}"]` : '[data-sctest="run-all"]'; + root.querySelector(selector).click(); + await new Promise((r) => setTimeout(r, 10)); + } + + vit("ConsoleReporter 在手动 suite 跑完后重新发出三行汇总", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("手动组", { auto: false }, () => { + i("会通过", () => e(1).toBe(1)); + i("会失败", () => e(1).toBe(2)); + }); + await run(); + + const lines = []; + const orig = console.log; + console.log = (...args) => lines.push(args.map(String).join(" ")); + try { + await clickRunAll(); + } finally { + console.log = orig; + } + + const text = lines.join("\n"); + vexpect(text).toMatch(/总测试数: 2/); + vexpect(/(通过|Passed)[::]\s*(\d+)/.exec(text)[2]).toBe("1"); + vexpect(/(失败|Failed)[::]\s*(\d+)/.exec(text)[2]).toBe("1"); + }); + + vit("LogReporter 在手动 suite 跑完后重新发出汇总日志", async () => { + const calls = []; + globalThis.GM_log = (message, level, labels) => calls.push({ message, level, labels }); + SCTest = await loadSCTest(); + + // reporter:"panel" 只会得到 console+panel,拿不到 LogReporter;用 __buildReporters + // 这个既有扩展点直接装配 LogReporter,并顺手捕获 runInfo 以触发手动运行。 + let captured = null; + const logReporter = SCTest.__createLogReporter(); + SCTest.__buildReporters = function (opts, context, runInfo) { + captured = runInfo; + return [logReporter]; + }; + + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo" }); + d("手动组", { auto: false }, () => i("会通过", () => e(1).toBe(1))); + await run(); + + calls.length = 0; + const summary = await captured.onRunManual("手动组"); + + const summaries = calls.filter((c) => c.labels && c.labels.sctest === "summary"); + vexpect(summaries.length).toBe(1); + vexpect(summaries[0].labels.passed).toBe(1); + vexpect(summaries[0].labels.failed).toBe(0); + vexpect(summary.passed).toBe(1); + vexpect(summary.skipped).toBe(0); + }); + + vit("onRunManual 返回本次运行后的 summary", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("手动组", { auto: false }, () => { + i("会通过", () => e(1).toBe(1)); + i("会失败", () => e(1).toBe(2)); + }); + const first = await run(); + vexpect(first.skipped).toBe(2); + vexpect(first.passed).toBe(0); + + await clickRunAll(); + + const root = document.getElementById("sctest-panel-host").shadowRoot; + const line = root.querySelector('[data-sctest="summary-line"]').textContent; + vexpect(line).toMatch(/通过: 1/); + vexpect(line).toMatch(/失败: 1/); + vexpect(line).toMatch(/跳过: 0/); + }); +}); From a1c448386e0f1608de0bd13353afd8a082810572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 21:19:10 +0800 Subject: [PATCH 20/28] =?UTF-8?q?=E2=9C=85=20=E6=B5=8B=E8=AF=95=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E6=96=B0=E5=A2=9E=E7=94=A8=E4=BE=8B=E5=86=85=E4=B8=BB?= =?UTF-8?q?=E5=8A=A8=E8=B7=B3=E8=BF=87=E9=80=9A=E9=81=93=20SCTest.skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移前 gm_download_test 的 runOne 特判错误消息的 "SKIP:" 前缀来区分跳过, 迁移到共用框架后这个通道没了,5 个手动用例超时或人工点 Skip 全部记为失败。 改用独立的 SkipSignal 类型而非消息前缀嗅探:前缀嗅探会把消息碰巧同名的 真实错误一并吞成跳过。STATUS.SKIP、summary.skipped、面板 sc-chip-skip 与 LogReporter 的 ○ 分支本来就在,这里补的是从用例体内产生 skip 的入口。 顺带修一个真实浏览器里复现的遮挡:verdict bar 原本 fixed 在右上角,与 右下角最高 80vh 的 sctest 面板重叠 2px,两者 z-index 同为最大值而面板挂载 更晚,Skip/Pass/Fail 按钮被吃掉点击。改到左上角。 --- example/tests/gm_download_test.js | 14 +++--- example/tests/lib/README.md | 15 ++++++ example/tests/lib/sctest.js | 34 ++++++++++--- example/tests/lib/sctest.test.js | 81 +++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 12 deletions(-) diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index e06264334..df8b8995d 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -165,7 +165,9 @@ const enableTool = true; { id: "gmdl-verdict-bar", style: { - position: "fixed", top: "12px", right: "12px", + // 靠左: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", @@ -731,7 +733,7 @@ const enableTool = true; }); 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) console.log(`note: no callbacks fired — Pass accepted but worth checking`); @@ -757,7 +759,7 @@ const enableTool = true; 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"); @@ -810,7 +812,7 @@ const enableTool = true; 300 ); 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) console.log(`note: you marked Pass without clicking Abort — test was inconclusive`); // The strong invariant: no successful onload after the user asked for abort. @@ -841,7 +843,7 @@ const enableTool = true; 300 ); 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})`); @@ -864,7 +866,7 @@ const enableTool = true; }); 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) console.log(`note: marked Pass but onload didn't fire — file presence is the source of truth here`); }); diff --git a/example/tests/lib/README.md b/example/tests/lib/README.md index e6ea0a668..b3e1b1743 100644 --- a/example/tests/lib/README.md +++ b/example/tests/lib/README.md @@ -54,6 +54,21 @@ run(); | `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"`: diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js index db10859df..8b7dee382 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -36,6 +36,12 @@ } } + // 用例体内主动跳过的信号。用独立类型而不是约定 message 前缀,是因为前缀嗅探会把 + // 消息碰巧同名的真实错误一并吞成跳过。 + function SkipSignal(reason) { + this.reason = reason || ""; + } + function AssertionError(message, expected, actual) { var err = new Error(message); err.name = "AssertionError"; @@ -212,10 +218,15 @@ await c.fn(); c.status = STATUS.PASS; } catch (e) { - c.status = STATUS.FAIL; - c.error = String((e && e.message) || e); - c.expected = (e && e.expected) || null; - c.actual = (e && e.actual) || null; + 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); } @@ -338,7 +349,7 @@ var hintSuffix = c.hint ? ":" + c.hint : ""; console.log("%c○ " + c.name + " (待人工确认" + hintSuffix + ")", "color: #999;"); } else { - console.log("%c○ " + c.name + " (跳过)", "color: #999;"); + console.log("%c○ " + c.name + " (跳过" + (c.error ? ": " + c.error : "") + ")", "color: #999;"); } }, onEnd: function (summary) { @@ -568,6 +579,11 @@ detail.setAttribute("data-sctest", "failure-detail"); node.row.parentNode.insertBefore(detail, node.row.nextSibling); node.detail = detail; + } else if (c.status === "skip" && c.error) { + var reason = el("div", "sc-detail", c.error); + reason.setAttribute("data-sctest", "skip-reason"); + node.row.parentNode.insertBefore(reason, node.row.nextSibling); + node.detail = reason; } } @@ -698,7 +714,10 @@ suite: c.suite, }); } else { - emitLog("○ " + c.suite + " › " + c.name, "warn", { sctest: "case", status: "skip" }); + emitLog("○ " + c.suite + " › " + c.name + (c.error ? " — " + c.error : ""), "warn", { + sctest: "case", + status: "skip", + }); } }, onEnd: function (summary) { @@ -723,6 +742,9 @@ var api = { create: create, + skip: function (reason) { + throw new SkipSignal(reason); + }, __detectContext: detectContext, __buildReporters: buildReporters, __createConsoleReporter: createConsoleReporter, diff --git a/example/tests/lib/sctest.test.js b/example/tests/lib/sctest.test.js index 0f605fd9d..c58fcd87a 100644 --- a/example/tests/lib/sctest.test.js +++ b/example/tests/lib/sctest.test.js @@ -255,6 +255,78 @@ vdescribe("sctest 框架内核", () => { vexpect(text).toMatch(/○ 无提示的人工用例 \(待人工确认\)/); }); }); + + vdescribe("用例内主动跳过", () => { + vit("SCTest.skip 让用例记为跳过而不是失败,原因随结果带出", async () => { + const { describe: d, it: i, run } = SCTest.create({ name: "demo", reporter: "console" }); + d("组", () => { + i("条件不满足", () => SCTest.skip("没有可用的下载目录")); + }); + + const summary = await run(); + + vexpect(summary.skipped).toBe(1); + vexpect(summary.failed).toBe(0); + vexpect(summary.suites[0].cases[0].status).toBe("skip"); + vexpect(summary.suites[0].cases[0].error).toBe("没有可用的下载目录"); + }); + + vit("跳过不影响同 suite 内后续用例执行", async () => { + const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "console" }); + d("组", () => { + i("跳过的", () => SCTest.skip("环境不支持")); + i("仍然跑", () => e(1).toBe(1)); + }); + + const summary = await run(); + + vexpect(summary.passed).toBe(1); + vexpect(summary.skipped).toBe(1); + vexpect(summary.failed).toBe(0); + }); + + // 迁移前的 gm_download_test 靠 message 的 "SKIP:" 前缀区分跳过,真实错误只要碰巧同名就会被吞掉。 + vit("消息以 SKIP: 开头的普通 Error 仍然记为失败", async () => { + const { describe: d, it: i, run } = SCTest.create({ name: "demo", reporter: "console" }); + d("组", () => { + i("真炸了", () => { + throw new Error("SKIP: 这其实是个真实错误"); + }); + }); + + const summary = await run(); + + vexpect(summary.failed).toBe(1); + vexpect(summary.skipped).toBe(0); + }); + + vit("ConsoleReporter 打印跳过原因", async () => { + const lines = []; + const orig = console.log; + console.log = (...args) => lines.push(args.map(String).join(" ")); + try { + const { describe: d, it: i, run } = SCTest.create({ name: "demo", reporter: "console" }); + d("组", () => i("条件不满足", () => SCTest.skip("需要人工先授权"))); + await run(); + } finally { + console.log = orig; + } + vexpect(lines.join("\n")).toMatch(/○ 条件不满足 \(跳过: 需要人工先授权\)/); + }); + + vit("LogReporter 把跳过原因写进日志正文", async () => { + const logged = []; + globalThis.GM_log = (msg, level, labels) => logged.push({ msg, level, labels }); + try { + const reporter = SCTest.__createLogReporter(); + reporter.onCase({ suite: "组", name: "条件不满足", status: "skip", error: "需要人工先授权", durationMs: 0 }); + } finally { + delete globalThis.GM_log; + } + vexpect(logged[0].msg).toMatch(/○ 组 › 条件不满足 — 需要人工先授权/); + vexpect(logged[0].labels.status).toBe("skip"); + }); + }); }); vdescribe("PanelReporter", () => { @@ -321,6 +393,15 @@ vdescribe("PanelReporter", () => { vexpect(root.querySelector('[data-sctest="summary-line"]').textContent).toMatch(/通过: 1/); }); + vit("跳过用例渲染出跳过原因", async () => { + const { describe: d, it: i, run } = SCTest.create({ name: "demo", reporter: "panel" }); + d("组", () => i("条件不满足", () => SCTest.skip("需要人工先授权"))); + await run(); + + const root = document.getElementById("sctest-panel-host").shadowRoot; + vexpect(root.querySelector('[data-sctest="skip-reason"]').textContent).toMatch(/需要人工先授权/); + }); + vit("auto:false 的 suite 渲染出运行按钮", async () => { const { describe: d, it: i, expect: e, run } = SCTest.create({ name: "demo", reporter: "panel" }); d("手动组", { auto: false, params: { prefix: "sc-test-" } }, () => i("a", () => e(1).toBe(1))); From 01f32500538b009d836758f110877dcb58188368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 22:14:03 +0800 Subject: [PATCH 21/28] =?UTF-8?q?=E2=9C=85=20gm=5Fxhr=5Ftest=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=E5=85=B1=E7=94=A8=E6=A1=86=E6=9E=B6=E5=B9=B6?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/gm-api.spec.ts | 240 +++++++- example/tests/gm_xhr_test.js | 1114 +++++++++++----------------------- 2 files changed, 579 insertions(+), 775 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 99e28a402..7ab28b91b 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -1,7 +1,7 @@ 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, type Page } from "@playwright/test"; import { autoApprovePermissions, installScriptByCode } from "./utils"; @@ -107,6 +107,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", "*"); @@ -128,6 +187,7 @@ async function startGMApiMockServer(): Promise { // the exact request URL it sent, search params included (mirrors real httpbun.com/get). url: `http://${req.headers.host}${url.pathname}${url.search}`, args, + headers: req.headers, }) ); return; @@ -170,6 +230,144 @@ async function startGMApiMockServer(): Promise { return; } + // 以下路由复刻 httpbun.com 上 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; + } + + const cookieSetMatch = url.pathname.match(/^\/cookies\/set\/([^/]+)\/([^/]+)$/); + if (cookieSetMatch) { + res.writeHead(200, { + "Content-Type": "application/json", + "Set-Cookie": `${cookieSetMatch[1]}=${cookieSetMatch[2]}; Path=/`, + }); + res.end(JSON.stringify({ cookies: parseCookies(req) })); + return; + } + + if (url.pathname === "/cookies/delete") { + res.writeHead(200, { + "Content-Type": "application/json", + "Set-Cookie": Object.keys(parseCookies(req)).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"] || ""}`; + let form: Record = {}; + let json: unknown = null; + if (contentType.includes("application/x-www-form-urlencoded")) { + form = Object.fromEntries(new URLSearchParams(data).entries()); + } + 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; + } + + // httpbun /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]); @@ -201,6 +399,16 @@ async function startGMApiMockServer(): Promise { ); }); + // Node 的 HTTP 解析器只认标准方法,会把 gm_xhr_test.js 的 `method: "FOOBAR"` 直接判为协议错误。 + // 真实 httpbun 对未知方法回 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); @@ -228,7 +436,7 @@ 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|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC|unwrap_e2e_test|GM_XHR_REDIRECT_TEST_SC|GM_DOWNLOAD_TEST_SC)$/gm, + /^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|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}` ); } @@ -257,6 +465,11 @@ function patchGMApiTestCode(code: string, mockOrigin: string): string { // 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://httpbun.com/... URLs. .replace(/const HB = "https:\/\/httpbun\.com";/, `const HB = "${mockOrigin}";`) + // gm_xhr_test.js 拉三个固定的 raw.githubusercontent.com 文件,按文件名映射到本地 /raw/。 + // 这两个域的 @connect 行刻意保持原样:改写后会和 httpbun 那行一起变成重复的 + // @connect 127.0.0.1,而重复的 @connect 值会让脚本完全不执行。 + .replace(/https:\/\/raw\.githubusercontent\.com\/\S*?\/([\w.-]+)\?/g, `${mockOrigin}/raw/$1?`) + .replace(/https:\/\/translate\.googleapis\.com/g, mockOrigin) ); } @@ -525,4 +738,27 @@ test.describe("GM API", () => { 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/gm_xhr_test.js b/example/tests/gm_xhr_test.js index 2cccdf56a..e8ad3b724 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@main/example/tests/lib/sctest.js // @connect httpbun.com // @connect nonexistent-domain-abcxyz.test // @connect raw.githubusercontent.com @@ -16,7 +17,6 @@ /* 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. @@ -45,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) => { @@ -87,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(); @@ -465,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"); }, }, { @@ -482,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"); }, }, { @@ -499,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"); }, }, @@ -517,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"); }, }, { @@ -534,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"); }, }, { @@ -551,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"); }, }, { @@ -568,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"); }, }, { @@ -585,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"); }, }, { @@ -601,11 +263,11 @@ const enableTool = true; url, fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); - assertEq(`${res.response}`.includes('"code": 200'), 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('"code": 200')).toBe(true); + expect(`${res.response}`.includes('"code": 200')).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -618,11 +280,11 @@ const enableTool = true; responseType: "", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); - assertEq(`${res.response}`.includes('"code": 200'), 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('"code": 200')).toBe(true); + expect(`${res.response}`.includes('"code": 200')).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -635,11 +297,11 @@ const enableTool = true; responseType: "text", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); - assertEq(`${res.response}`.includes('"code": 200'), 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('"code": 200')).toBe(true); + expect(`${res.response}`.includes('"code": 200')).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -652,11 +314,11 @@ const enableTool = true; responseType: "json", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); - assertEq(typeof res.response === "object" && res.response?.code === 200, 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('"code": 200')).toBe(true); + expect(typeof res.response === "object" && res.response?.code === 200).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -669,11 +331,11 @@ const enableTool = true; responseType: "document", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), 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('"code": 200')).toBe(true); + expect(res.response instanceof XMLDocument).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -686,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"); }, }, { @@ -703,11 +365,11 @@ const enableTool = true; responseType: "arraybuffer", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), 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('"code": 200')).toBe(true); + expect(res.response instanceof ArrayBuffer).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -720,11 +382,11 @@ const enableTool = true; responseType: "blob", fetch, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), 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('"code": 200')).toBe(true); + expect(res.response instanceof Blob).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -736,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"); }, }, { @@ -753,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"); }, }, { @@ -770,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"); }, }, { @@ -787,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"); }, }, { @@ -804,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"); }, }, { @@ -821,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"); }, }, { @@ -838,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"); }, }, { @@ -855,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"); }, }, { @@ -873,13 +535,13 @@ const enableTool = true; fetch, }); const body = JSON.parse(res.responseText); - assertEq(res.status, 200, "status 200"); + expect(res.status).toBe(200); const q = getQueryObj(body); - assertEq(q.x, "1", "query args"); + expect(q.x).toBe("1"); const hdrs = body.headers || {}; - assertEq(hdrs["X-Custom"] || hdrs["x-custom"], "Hello", "custom header echo"); - assertEq(res.finalUrl, url, "finalUrl matches"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(hdrs["X-Custom"] || hdrs["x-custom"]).toBe("Hello"); + expect(res.finalUrl).toBe(url); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -892,9 +554,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"); }, }, { @@ -908,9 +570,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"); }, }, { @@ -932,11 +594,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"); } }, }, @@ -955,10 +617,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"); - 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 === "string" && res?.responseHeaders !== "").toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -973,10 +635,10 @@ const enableTool = true; fetch, }); const body = JSON.parse(res.responseText); - assertEq(res.status, 200); - assertEq((body.form || {}).a, "1", "form a"); - assertEq((body.form || {}).b, "two", "form b"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(res.status).toBe(200); + expect((body.form || {}).a).toBe("1"); + expect((body.form || {}).b).toBe("two"); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -991,9 +653,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"); }, }, { @@ -1008,9 +670,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"); }, }, { @@ -1027,11 +689,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"); }, }, { @@ -1049,12 +711,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; httpbun returns octet-stream here. }, }, @@ -1068,10 +730,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"); }, }, { @@ -1083,10 +745,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(); }, }, { @@ -1098,10 +760,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(); }, }, { @@ -1113,9 +775,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"); }, }, { @@ -1130,7 +792,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"); } }, }, @@ -1176,14 +838,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( { @@ -1191,7 +853,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( { @@ -1199,7 +861,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( { @@ -1207,7 +869,7 @@ const enableTool = true; }, { abortAfterMs: 500 } ); - assertDeepEq(nwError2.events, ["onerror", "onloadend"], "abort fires onerror then onloadend"); + expect(nwError2.events).toEqual(["onerror", "onloadend"]); }, }, { @@ -1226,7 +888,6 @@ const enableTool = true; 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 }), @@ -1235,12 +896,12 @@ const enableTool = true; fetch, }); }); - assertEq(res.status, 200); - assert(progressEvents >= 4, "received at least 4 progress events"); + expect(res.status).toBe(200); + expect(progressEvents >= 4).toBeTruthy(); // `progress` is guaranteed to fire only in the Fetch API. - assert(fetch ? lastLoaded > 0 : lastLoaded >= 0, "progress loaded captured"); - assert(!response, "no response"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(fetch ? lastLoaded > 0 : lastLoaded >= 0).toBeTruthy(); + expect(!response).toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1266,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; @@ -1279,12 +939,12 @@ const enableTool = true; fetch, }); }); - assertEq(res.status, 200); - assert(progressEvents >= 4, "received at least 4 progress events"); + expect(res.status).toBe(200); + expect(progressEvents >= 4).toBeTruthy(); // `progress` is guaranteed to fire only in the Fetch API. - assert(fetch ? lastLoaded > 0 : lastLoaded >= 0, "progress loaded captured"); - assert(response instanceof ReadableStream && typeof response.getReader === "function", "response"); - assertEq(objectProps(res), "ok", "Object Props OK"); + expect(fetch ? lastLoaded > 0 : lastLoaded >= 0).toBeTruthy(); + expect(response instanceof ReadableStream && typeof response.getReader === "function").toBeTruthy(); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1295,10 +955,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"); }, }, { @@ -1309,10 +969,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"); }, }, { @@ -1324,8 +984,8 @@ const enableTool = true; fetch, }); // httpbun 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"); }, }, { @@ -1336,10 +996,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"); }, }, { @@ -1362,11 +1022,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"); }, }, { @@ -1381,8 +1041,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"); }, }, { @@ -1396,8 +1056,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"); }, }, { @@ -1436,8 +1096,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"); }, }, { @@ -1464,11 +1124,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"); }, }, { @@ -1479,8 +1139,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 }, }, @@ -1493,8 +1153,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"); }, }, { @@ -1509,22 +1169,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"); } }, }, @@ -1539,22 +1195,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"); } }, }, @@ -1569,13 +1221,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"); } }, }, @@ -1596,7 +1248,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"); } }, }, @@ -1611,11 +1263,11 @@ const enableTool = true; fetch, onprogress() {}, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); - assertEq(typeof res.response === "object" && res.response?.code === 200, 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('"code": 200')).toBe(true); + expect(typeof res.response === "object" && res.response?.code === 200).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1632,12 +1284,12 @@ const enableTool = true; readyStateList.push(resp.readyState); }, }); - assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); - assertEq(typeof res.response === "object" && res.response?.code === 200, 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('"code": 200')).toBe(true); + expect(typeof res.response === "object" && res.response?.code === 200).toBe(true); + expect(res.responseXML instanceof XMLDocument).toBe(true); + expect(readyStateList).toEqual(fetch ? [2, 4] : [1, 2, 3, 4]); + expect(objectProps(res)).toBe("ok"); }, }, { @@ -1666,29 +1318,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", + ]); } }, }, @@ -1725,34 +1369,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", + ]); } }, }, @@ -1789,34 +1425,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", + ]); } }, }, @@ -1851,14 +1479,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); }, }, { @@ -1894,18 +1518,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" || 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); }, }, { @@ -1917,15 +1533,15 @@ const enableTool = true; 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"); }, }, ]; @@ -1937,18 +1553,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() + ":")); @@ -1981,49 +1585,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(); })(); From dde2ad7c3dd1addfc32acb60a50b8f8d3826cca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 22:57:20 +0800 Subject: [PATCH 22/28] =?UTF-8?q?=E2=9C=85=20gm=5Fmenu=5Ftest=20=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=E5=85=B1=E7=94=A8=E6=A1=86=E6=9E=B6=EF=BC=8C?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=E6=A3=80=E6=9F=A5=E5=8D=87=E7=BA=A7=E4=B8=BA?= =?UTF-8?q?=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 处 GM_registerMenuCommand 返回值契约检查从软打印 console.log(x === y) 升级为真断言 expect(x).toBe(y),放进 it() 用例;scratch 自动核过全部通过(总20 通过11 失败0 跳过9)。菜单点击观察点转 itManual() 按注册顺序交错;保留三个调试开关与全部注册/注销调用及回调内 console.log 面包屑,仅删除 waitActions/myResolve/waitNext 等待机制。gm_value_test.js 按决定不在本次范围,保持原样。 --- example/tests/gm_menu_test.js | 223 +++++++++++++--------------------- 1 file changed, 84 insertions(+), 139 deletions(-) diff --git a/example/tests/gm_menu_test.js b/example/tests/gm_menu_test.js index 216015dfb..37e2f8afe 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@main/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"); }); - From 3038fb02c8003839c1ad5396c62558d734e98d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 23:17:18 +0800 Subject: [PATCH 23/28] =?UTF-8?q?=E2=9C=85=20gm=5Fxhr=5Fcookie=5Ftest=20?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0=E5=85=B1=E7=94=A8=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 个 test() → 12 个 it(),归入 3 个 describe。assert(expected,actual) 是期望在前, 转成 expect(actual).toBe(expected) 逐条换位;assertTrue → toBeTruthy。领域 helper assertCookieValues 保签名与 slice().sort() 集合语义不变,内部 assert 改写为 expect(JSON.stringify(actual)).toBe(JSON.stringify(expected))。 矩阵段的 9 个依赖用例:原文用 `if (matrixRequestPassed && lastCookieMap)` 门控 (请求失败则这 9 个 test 不注册)。声明式框架无法条件注册,故引入 matrixOk 标志 + 每个依赖用例开头 `if (!matrixOk) SCTest.skip(...)`,复刻「前置请求失败则跳过依赖用例 而非各自级联失败」的原语义。 对真实 mockhttp.org 各跑 2 次稳定:迁移前基线 12/12/0,迁移后 12/12/0(含 skip 守卫, happy path 无跳过)。 --- example/tests/gm_xhr_cookie_test.js | 321 +++++++++------------------- 1 file changed, 104 insertions(+), 217 deletions(-) diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js index f473c66b0..d38a8ff9d 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@main/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;" - ); - } })(); From ebf32b11febe545f556bfc216fccff7eb0e5a13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 23:35:01 +0800 Subject: [PATCH 24/28] =?UTF-8?q?=F0=9F=93=9D=20=E6=9B=B4=E6=96=B0=20verif?= =?UTF-8?q?ication=20=E6=96=87=E6=A1=A3=E4=BB=A5=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=90=8E=E7=9A=84=E6=B5=8B=E8=AF=95=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit example/tests 下 14 个脚本迁移到 sctest.js 后,in-page self-test 只剩一种汇总格式, 不再有原来三种方言。改写「self-test pattern」小节:统一为框架的三行汇总,说明 background/crontab 走 GM_log、人工用例走 itManual,并标注 gm_value_test.js 是刻意 不迁移的交互式演示(无可判定断言、不打印汇总)。正则示例保留,注释从「三种布局」改为 「框架汇总行」。 --- docs/verification.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) 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[] = []; From 5bd1142b4f763500a0ab504397295c82c8b30e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 21 Jul 2026 23:43:59 +0800 Subject: [PATCH 25/28] =?UTF-8?q?=F0=9F=93=9D=20=E8=AE=B0=E5=BD=95=20sctes?= =?UTF-8?q?t=20deepEqual=20=E5=AF=B9=20Date/Map/Set=20=E7=9A=84=E5=B7=B2?= =?UTF-8?q?=E7=9F=A5=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 终审建议(Minor ①): deepEqual 把 Date/RegExp/Map/Set 当普通对象比较,两个不同 Date 会相等。当前迁移用例的 toEqual 均未触及这些类型,仅补注释说明契约边界,行为不变。 --- example/tests/lib/sctest.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js index 8b7dee382..4aed2fedf 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -54,6 +54,8 @@ // 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; From e2fe707ec51bd70d3cd46c24df30a872c8d78cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 22 Jul 2026 12:54:49 +0800 Subject: [PATCH 26/28] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20SCTes?= =?UTF-8?q?t=20=E9=9D=A2=E6=9D=BF=20CSP=20=E6=B3=A8=E5=85=A5=E4=B8=8E?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/gm-api.spec.ts | 56 +++- example/tests/gm_download_test.js | 6 +- example/tests/lib/sctest.js | 487 ++++++++++++++++++++++++++---- example/tests/lib/sctest.test.js | 97 +++++- 4 files changed, 578 insertions(+), 68 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 13e0d959c..6105f19a9 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -27,6 +27,15 @@ type GMApiMockServer = { close: () => Promise; }; +type SCTestBrowserApi = { + 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; @@ -551,15 +560,21 @@ async function runTestScript( return { passed, failed, logs }; } -// 点击某个 auto:false suite 的面板运行按钮。结果由 sctest.js 的 ConsoleReporter 在 -// runManualSuites 结束时重新打出一整组汇总,runTestScript 照常从 console 解析。 +// 设计稿统一为“运行全部”入口;旧面板若仍提供 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 }); - await host.evaluate((el: HTMLElement, name: string) => { - el.shadowRoot?.querySelector(`[data-sctest-suite="${name}"]`)?.click(); + 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); }; } @@ -597,6 +612,37 @@ 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("case", () => testRun.expect(1).toBe(1))); + 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([]); + await page.close(); + }); + test("GM_ sync API tests (gm_api_sync_test.js)", async ({ context, extensionId }) => { const { passed, failed, logs } = await runTestScript( context, @@ -763,7 +809,7 @@ test.describe("GM API", () => { { patchCode, requireOrigin: gmApiMockServer.origin, - // 两个 suite 都是 auto:false,页面加载不会自动跑;只点自动套件的运行按钮,手动用例保持不跑。 + // 两个 suite 都是 auto:false;统一入口会执行自动用例,itManual 仍保持待人工确认。 beforeCollect: clickSuiteRunButton("GM_download 自动套件"), } ); diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index 7a216aac5..58319ee34 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -65,8 +65,8 @@ 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 sctest panel appears bottom-right. Click "运行 GM_download 自动套件" for the automated - battery, or "运行 GM_download 手动用例" for the human-in-the-loop cases. + 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. */ @@ -873,7 +873,7 @@ const enableTool = true; // ---------- Suites ---------- // manual: true 用例原本被 runAuto()(旧版 :1083)用 tests.filter((t) => !t.manual) 排除在批量 - // 之外;迁移拆成两个 auto:false 的 suite,各自一个运行按钮,保持这个区分。 + // 之外;迁移拆成两个 auto:false 的 suite,“运行全部”只执行自动用例,人工用例仍逐条确认。 describe("GM_download 自动套件", { auto: false, params: autoParams }, () => { for (const t of tests.filter((t) => !t.manual)) { it(t.name, () => t.run()); diff --git a/example/tests/lib/sctest.js b/example/tests/lib/sctest.js index 4aed2fedf..722447286 100644 --- a/example/tests/lib/sctest.js +++ b/example/tests/lib/sctest.js @@ -270,11 +270,11 @@ }; } - async function runManualSuites(reporters, onlySuiteName) { + async function rerunSuites(reporters, onlySuiteName, includeAutoSuites) { var startedAt = now(); for (var i = 0; i < suites.length; i++) { var suite = suites[i]; - if (suite.auto) continue; + 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]; @@ -298,7 +298,10 @@ var runInfo = { name: runName, context: context, suites: suites, onRunManual: null }; var reporters = global.SCTest.__buildReporters(opts, context, runInfo); runInfo.onRunManual = function (suiteName) { - return runManualSuites(reporters, suiteName); + return rerunSuites(reporters, suiteName, false); + }; + runInfo.onRerun = function () { + return rerunSuites(reporters, null, true); }; var startedAt = now(); reporters.forEach(function (r) { @@ -374,49 +377,90 @@ ".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-title{font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}", + ".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{cursor:pointer;border:1px solid var(--sc-border);background:var(--sc-card);color:var(--sc-fg);", + ".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-chip{border-radius:9999px;padding:3px 9px;font-size:11px;font-weight:500}", + ".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 span{flex:1}", + ".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{margin:0 14px 8px 34px;padding:7px 10px;border-radius:6px;background:var(--sc-muted-bg);", + ".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 input{flex:1;border:1px solid var(--sc-border);border-radius:6px;padding:3px 8px;", + ".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-fg:#0c8833;--sc-success-bg:#e8f9ec;", + "--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-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-destructive-fg:#ff9a9a;--sc-destructive-bg:#3a1a1c;--sc-warning-bg:#352c1e;--sc-warning-fg:#ffb84d}}", ].join(""); + // Constructable stylesheet 通过 CSSOM 安装到 Shadow Root,不属于页面的 inline