From cb83b0b174c9861e70b978efaa42c84883e7fdb7 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:35:15 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20=E7=BB=9F=E4=B8=80=20example/tests?= =?UTF-8?q?=20=E5=86=85=E9=83=A8=E7=BB=93=E6=9E=84=EF=BC=8C=E5=8E=BB?= =?UTF-8?q?=E9=99=A4=20innerHTML=20=E7=9B=B4=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 PR #1631 中 cyfung1031 的评审意见(每个 userscript 测试必须自包含、 不引用外部/共享测试库,一眼看完全部逻辑)重新组织 15 个 example/tests 测试脚本:将与具体测试对象无关的基建代码(计数器、test/assert 断言函数、 控制台格式化、结果面板 DOM 生成等)收拢到内层 IIFE 并以对象形式返回, 外层 IIFE 解构接收后只保留原有测试内容,写法与顺序不变;同类概念在各 文件间统一命名(test/assert/assertTrue/printSummary/showResultPanel 等)。 同时把所有 .innerHTML 直接赋值 / insertAdjacentHTML 替换为 createElement/textContent/appendChild 构造,避免 CSP 不兼容。 不引入任何新的共享文件或本地 @require,每个脚本保持独立可运行。 Co-Authored-By: Claude Sonnet 5 --- example/tests/early_inject_content_test.js | 185 +-- example/tests/early_inject_page_test.js | 183 +-- example/tests/gm_api_async_test.js | 330 ++--- example/tests/gm_api_sync_test.js | 110 +- example/tests/gm_download_test.js | 689 +++++---- example/tests/gm_menu_test.js | 90 +- example/tests/gm_value_test.js | 211 +-- example/tests/gm_xhr_cookie_test.js | 130 +- example/tests/gm_xhr_redirect_test.js | 261 ++-- example/tests/gm_xhr_test.js | 1503 ++++++++++---------- example/tests/inject_content_test.js | 107 +- example/tests/sandbox_test.js | 397 +++--- example/tests/unwrap_e2e_test.js | 53 +- example/tests/unwrap_test.js | 59 +- example/tests/window_message_test.js | 143 +- 15 files changed, 2368 insertions(+), 2083 deletions(-) diff --git a/example/tests/early_inject_content_test.js b/example/tests/early_inject_content_test.js index ad28dcfed..f2e39d851 100644 --- a/example/tests/early_inject_content_test.js +++ b/example/tests/early_inject_content_test.js @@ -18,93 +18,40 @@ // @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 || "断言失败: 期望条件为真"); - } -} +(async ({ test, testSync, assert, assertTrue, printSummary }) => { + console.log("%c=== Content环境 GM API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); -console.log("%c=== Content环境 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;"); -// 同步测试 - -// ============ 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", + testSync("GM_addElement", () => { + const element = GM_addElement("div", { + textContent: "GM_addElement测试元素", + style: "display:none;", + id: "gm-test-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); }); - 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(` + + testSync("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); -}); - -(async function () { - "use strict"; + assertTrue(styleElement !== null && styleElement !== undefined, "GM_addStyle应该返回样式元素"); + assertTrue(styleElement.tagName === "STYLE" || styleElement.sheet, "应该返回STYLE元素或样式对象"); + console.log("返回样式元素:", styleElement); + // 清理测试样式 + styleElement.parentNode.removeChild(styleElement); + }); // ============ 早期脚本环境检查 ============ console.log("\n%c--- 早期脚本环境检查 ---", "color: orange; font-weight: bold;"); @@ -215,15 +162,73 @@ testSync("GM_addStyle", () => { }); // ============ 输出测试结果 ============ - 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;"); + printSummary(); +})((() => { + 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; + } + } + + 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 || "断言失败: 期望条件为真"); + } + } + + // printSummary() - 输出测试结果汇总 + function printSummary() { + 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;"); + } + } + + return { test, testSync, assert, assertTrue, printSummary }; +})()); diff --git a/example/tests/early_inject_page_test.js b/example/tests/early_inject_page_test.js index 9d6e9d0bb..d58c02157 100644 --- a/example/tests/early_inject_page_test.js +++ b/example/tests/early_inject_page_test.js @@ -17,91 +17,38 @@ // @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, -}; +(async ({ test, testSync, assert, assertTrue, printSummary }) => { + console.log("%c=== 早期脚本 GM API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); -// ============ GM_addElement/GM_addStyle 测试 ============ -console.log("\n%c--- DOM操作 API 测试 ---", "color: orange; font-weight: bold;"); + // ============ 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", + testSync("GM_addElement", () => { + const element = GM_addElement("div", { + textContent: "GM_addElement测试元素", + style: "display:none;", + id: "gm-test-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); }); - 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(` + + testSync("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); -}); - -(async function () { - "use strict"; + assertTrue(styleElement !== null && styleElement !== undefined, "GM_addStyle应该返回样式元素"); + assertTrue(styleElement.tagName === "STYLE" || styleElement.sheet, "应该返回STYLE元素或样式对象"); + console.log("返回样式元素:", styleElement); + // 清理测试样式 + styleElement.parentNode.removeChild(styleElement); + }); // ============ 早期脚本环境检查 ============ console.log("\n%c--- 早期脚本环境检查 ---", "color: orange; font-weight: bold;"); @@ -241,15 +188,73 @@ testSync("GM_addStyle", () => { }); // ============ 输出测试结果 ============ - 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;"); + printSummary(); +})((() => { + 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; + } } -})(); + + 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 || "断言失败: 期望条件为真"); + } + } + + // printSummary() - 输出测试结果汇总 + function printSummary() { + 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;"); + } + } + + return { test, testSync, assert, assertTrue, printSummary }; +})()); diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index c8535935c..a6f022b9e 100644 --- a/example/tests/gm_api_async_test.js +++ b/example/tests/gm_api_async_test.js @@ -29,44 +29,14 @@ // @run-at document-start // ==/UserScript== -(async function () { - "use strict"; +"use strict"; +(async ({ test, assert, printSummary, showResultPanel }) => { 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 () => { + await test("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 应该存在"); @@ -76,25 +46,25 @@ // ============ GM 存储 API 测试 ============ console.log("\n%c--- GM 存储 API 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("GM.setValue - 字符串", async () => { + await test("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 应该返回正确的字符串值"); }); - await testAsync("GM.setValue - 数字", async () => { + await test("GM.setValue - 数字", async () => { await GM.setValue("test_number", 42); const value = await GM.getValue("test_number"); assert(42, value, "GM.getValue 应该返回正确的数字值"); }); - await testAsync("GM.setValue - 布尔值", async () => { + await test("GM.setValue - 布尔值", async () => { await GM.setValue("test_boolean", true); const value = await GM.getValue("test_boolean"); assert(true, value, "GM.getValue 应该返回正确的布尔值"); }); - await testAsync("GM.setValue - 对象", async () => { + await test("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"); @@ -104,7 +74,7 @@ assert(JSON.stringify(obj.features), JSON.stringify(value.features), "features 数组应该相等"); }); - await testAsync("GM.setValue - 数组", async () => { + await test("GM.setValue - 数组", async () => { const arr = [1, 2, 3, "test", { key: "value" }]; await GM.setValue("test_array", arr); const value = await GM.getValue("test_array"); @@ -115,19 +85,19 @@ assert(arr[4].key, value[4].key, "对象元素的属性应该相等"); }); - await testAsync("GM.getValue - 默认值", async () => { + await test("GM.getValue - 默认值", async () => { const value = await GM.getValue("non_existent_key", "default_value"); assert("default_value", value, "不存在的键应该返回默认值"); }); - await testAsync("GM.listValues", async () => { + await test("GM.listValues", async () => { const values = await GM.listValues(); assert(true, Array.isArray(values), "GM.listValues 应该返回数组"); assert(true, values.includes("test_string"), "应该包含已存储的键"); console.log("存储的键:", values); }); - await testAsync("GM.deleteValue", async () => { + await test("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"); @@ -137,7 +107,7 @@ // ============ GM.addStyle 测试 ============ console.log("\n%c--- GM 样式 API 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("GM.addStyle - CSS字符串", async () => { + await test("GM.addStyle - CSS字符串", async () => { const css = ` .scriptcat-test-async { color: blue; @@ -150,7 +120,7 @@ }); // ============ GM.addElement 测试 ============ - await testAsync("GM.addElement - 创建元素", async () => { + await test("GM.addElement - 创建元素", async () => { assert("function", typeof GM.addElement, "GM.addElement 应该是函数"); const div = await GM.addElement("div", { @@ -167,7 +137,7 @@ // ============ GM.getResourceText/Url 测试 ============ console.log("\n%c--- GM 资源 API 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("GM.getResourceText", async () => { + await test("GM.getResourceText", async () => { assert("function", typeof GM.getResourceText, "GM.getResourceText 应该是函数"); const css = await GM.getResourceText("testCSS"); @@ -176,7 +146,7 @@ console.log("资源文本长度:", css.length); }); - await testAsync("GM.getResourceUrl", async () => { + await test("GM.getResourceUrl", async () => { assert("function", typeof GM.getResourceUrl, "GM.getResourceUrl 应该是函数"); const url = await GM.getResourceUrl("testCSS"); @@ -188,7 +158,7 @@ // ============ GM.xmlHttpRequest 测试 ============ console.log("\n%c--- GM 网络请求 API 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("GM.xmlHttpRequest - GET 请求", async () => { + await test("GM.xmlHttpRequest - GET 请求", async () => { return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: "GET", @@ -217,7 +187,7 @@ }); }); - await testAsync("GM.xmlHttpRequest - 返回控制对象", async () => { + await test("GM.xmlHttpRequest - 返回控制对象", async () => { const controller = GM.xmlHttpRequest({ method: "GET", url: "https://httpbun.com/get", @@ -234,7 +204,7 @@ // ============ GM.notification 测试 ============ console.log("\n%c--- GM 通知 API 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("GM.notification - Promise 版本", async () => { + await test("GM.notification - Promise 版本", async () => { assert("function", typeof GM.notification, "GM.notification 应该是函数"); const notificationPromise = GM.notification({ @@ -258,7 +228,7 @@ // ============ GM.setClipboard 测试 ============ console.log("\n%c--- GM 剪贴板 API 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("GM.setClipboard", async () => { + await test("GM.setClipboard", async () => { assert("function", typeof GM.setClipboard, "GM.setClipboard 应该是函数"); await GM.setClipboard("ScriptCat GM.* API 测试文本 - " + new Date().toLocaleString()); @@ -268,7 +238,7 @@ // ============ GM.openInTab 测试 ============ console.log("\n%c--- GM 标签页 API 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("GM.openInTab (不执行)", async () => { + await test("GM.openInTab (不执行)", async () => { // 不实际打开标签页,只测试函数是否存在 assert("function", typeof GM.openInTab, "GM.openInTab 应该是函数"); console.log("GM.openInTab 可用 (未实际打开标签页)"); @@ -277,7 +247,7 @@ // ============ GM.registerMenuCommand 测试 ============ console.log("\n%c--- GM 菜单 API 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("GM.registerMenuCommand", async () => { + await test("GM.registerMenuCommand", async () => { const menuId = await GM.registerMenuCommand("ScriptCat 异步测试菜单", () => { alert("异步测试菜单被点击!"); }); @@ -288,12 +258,12 @@ // ============ GM.cookie 测试 ============ console.log("\n%c--- GM.cookie API 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("GM.cookie 函数存在", async () => { + await test("GM.cookie 函数存在", async () => { assert("function", typeof GM.cookie, "GM.cookie 应该是一个函数"); console.log("GM.cookie API 可用"); }); - await testAsync("GM.cookie.set", async () => { + await test("GM.cookie.set", async () => { await GM.cookie.set({ url: "http://example.com/cookie", name: "scriptcat_async_test1", @@ -302,7 +272,7 @@ console.log("Cookie 已设置: scriptcat_async_test1 @ example.com"); }); - await testAsync("GM.cookie.set (带 domain 和 path)", async () => { + await test("GM.cookie.set (带 domain 和 path)", async () => { await GM.cookie.set({ url: "http://www.example.com/", domain: ".example.com", @@ -313,7 +283,7 @@ console.log("Cookie 已设置: scriptcat_async_test2 @ .example.com/path"); }); - await testAsync("GM.cookie.list (by domain)", async () => { + await test("GM.cookie.list (by domain)", async () => { const cookies = await GM.cookie.list({ domain: "example.com", }); @@ -323,7 +293,7 @@ console.log("示例 Cookie:", cookies[0]); }); - await testAsync("GM.cookie.list (by url)", async () => { + await test("GM.cookie.list (by url)", async () => { const cookies = await GM.cookie.list({ url: "http://example.com/cookie", }); @@ -331,7 +301,7 @@ console.log("通过 URL 列出的 cookies:", cookies.length, "个"); }); - await testAsync("GM.cookie.delete", async () => { + await test("GM.cookie.delete", async () => { await GM.cookie.delete({ url: "http://www.example.com/path", name: "scriptcat_async_test2", @@ -339,7 +309,7 @@ console.log("Cookie 已删除: scriptcat_async_test2"); }); - await testAsync("GM.cookie - 验证删除后", async () => { + await test("GM.cookie - 验证删除后", async () => { const cookies = await GM.cookie.list({ domain: "example.com", }); @@ -349,7 +319,7 @@ }); // 清理所有测试 cookies - await testAsync("清理测试 cookies", async () => { + await test("清理测试 cookies", async () => { const cookies = await GM.cookie.list({ domain: "example.com" }); const testCookies = cookies.filter((c) => c.name.startsWith("scriptcat_async_test")); @@ -372,7 +342,7 @@ // ============ unsafeWindow 测试 ============ console.log("\n%c--- unsafeWindow 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("unsafeWindow", async () => { + await test("unsafeWindow", async () => { assert("object", typeof unsafeWindow, "unsafeWindow 应该存在"); assert(document, unsafeWindow.document, "unsafeWindow.document 应该等于 document"); console.log("unsafeWindow 可用"); @@ -381,28 +351,67 @@ // ============ @require 测试 ============ console.log("\n%c--- @require 测试 ---", "color: orange; font-weight: bold;"); - await testAsync("jQuery 加载 (@require)", async () => { + await test("jQuery 加载 (@require)", async () => { 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)}%`); + printSummary(); // 使用 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: ` + await showResultPanel(); + + console.log("%c=== ScriptCat GM.* API 测试完成 ===", "color: blue; font-size: 16px; font-weight: bold;"); +})((() => { + 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; + } + } + + // 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); + } + } + + function printSummary() { + 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)}%`); + } + + async function showResultPanel() { + 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; @@ -416,10 +425,10 @@ min-width: 350px; animation: slideIn 0.5s ease-out; `, - }); + }); - // 添加动画样式 - await GM.addStyle(` + // 添加动画样式 + await GM.addStyle(` @keyframes slideIn { from { transform: translateX(400px); @@ -432,67 +441,67 @@ } `); - // 标题 - 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;", - }); + // 标题 + 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 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 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 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 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;", - }); + // 成功率 + 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;", + }); - // 进度条 - 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: ` + // 进度条 + 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}%; @@ -504,18 +513,18 @@ font-size: 12px; font-weight: bold; `, - textContent: successRate + "%", - }); + textContent: successRate + "%", + }); - // 按钮容器 - const buttonContainer = await GM.addElement(resultContainer, "div", { - style: "display: flex; gap: 10px; margin-top: 15px;", - }); + // 按钮容器 + const buttonContainer = await GM.addElement(resultContainer, "div", { + style: "display: flex; gap: 10px; margin-top: 15px;", + }); - // 关闭按钮 - const closeBtn = await GM.addElement(buttonContainer, "button", { - textContent: "关闭", - style: ` + // 关闭按钮 + const closeBtn = await GM.addElement(buttonContainer, "button", { + textContent: "关闭", + style: ` flex: 1; padding: 8px 15px; cursor: pointer; @@ -527,15 +536,15 @@ 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: ` + }); + 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; @@ -547,13 +556,14 @@ 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("请查看控制台中的详细测试日志"); - }; + }); + 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;"); -})(); + return { test, assert, printSummary, showResultPanel }; +})()); diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index 7b6008ce2..4cfeb4ddb 100644 --- a/example/tests/gm_api_sync_test.js +++ b/example/tests/gm_api_sync_test.js @@ -33,55 +33,11 @@ // @run-at document-start // ==/UserScript== -(async function () { - "use strict"; +"use strict"; +(async ({ test, testAsync, assert, printSummary, showResultPanel }) => { 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 存在", () => { @@ -588,13 +544,67 @@ }); // ============ 测试总结 ============ + printSummary(); + + // 使用 GM_addElement 在页面上显示结果 + showResultPanel(); + + console.log("%c=== ScriptCat GM API 测试完成 ===", "color: blue; font-size: 16px; font-weight: bold;"); + })(); +})((() => { + const 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); + } + } + + function printSummary() { 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 在页面上显示结果 + function showResultPanel() { const successRate = ((testResults.passed / testResults.total) * 100).toFixed(2); const bgColor = testResults.failed === 0 ? "#d4edda" : testResults.failed < testResults.total / 2 ? "#fff3cd" : "#f8d7da"; @@ -754,7 +764,7 @@ 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;"); - })(); -})(); + return { test, testAsync, assert, printSummary, showResultPanel }; +})()); diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index 8814de5ca..d08d34973 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -69,33 +69,13 @@ Click "Set prefix" to change it. Click "Clear log" to reset counts. */ +"use strict"; + const enableTool = true; -(function () { - "use strict"; +(async ({ h, escapeHtml, fmtMs, btnStyle, logLine, pass, fail, skip, setStatus, setQueue, showProgress, updateProgress, hideProgress, assert, assertTrue, withTimeout, awaitVerdict, showAwaitingAction, hideAwaiting, printSummary, showResultPanel }) => { if (!enableTool) return; - // ---------- Tiny 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; - } - - function escapeHtml(s) { - return String(s).replace( - /[&<>"']/g, - (m) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[m] - ); - } - - function fmtMs(ms) { - return ms < 1000 ? `${ms | 0}ms` : `${(ms / 1000).toFixed(2)}s`; - } + const { panel, $manualButtons, $prefix, $runTagLabel } = showResultPanel(); // ---------- Settings (persisted) ---------- // Prefix is the sub-folder under the user's Downloads dir. Trailing slash auto-appended. @@ -139,141 +119,9 @@ 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 wiring that depends on this suite's own settings/runner ---------- + $prefix.textContent = getPrefix(); + $runTagLabel.textContent = `RunTag: ${RUN_TAG}`; panel.querySelector("#start").addEventListener("click", () => runAuto()); panel.querySelector("#setPrefix").addEventListener("click", () => { const cur = getPrefix(); @@ -284,150 +132,6 @@ const enableTool = true; 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; - const t = setTimeout(() => { - if (done) return; - done = true; - reject(new Error(`timed out after ${ms}ms: ${label || ""}`)); - }, ms); - p.then((v) => { if (done) return; done = true; clearTimeout(t); resolve(v); }, - (e) => { if (done) return; done = true; clearTimeout(t); reject(e); }); - }); - } - - // ---------- Awaiting bar (manual-test verdict UI) ---------- - // 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. - 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 = ""; - _verdictDeadline = performance.now() + deadlineSecs * 1000; - tickAwaitingTimer(); - if (_verdictTimerId) clearInterval(_verdictTimerId); - _verdictTimerId = setInterval(tickAwaitingTimer, 250); - } - function tickAwaitingTimer() { - const remaining = Math.max(0, Math.ceil((_verdictDeadline - performance.now()) / 1000)); - $awaitingTimer.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 = ""; - 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; - } - function resolveVerdict(v) { - if (!_verdictResolve) return; - const r = _verdictResolve; - _verdictResolve = null; - hideAwaiting(); - r(v); - } - $awaitingPass.addEventListener("click", () => resolveVerdict({ verdict: "pass" })); - $awaitingFail.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" })); - - /** - * 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). - * @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) { - return new Promise((resolve) => { - _verdictResolve = resolve; - showAwaiting(promptHtml, deadlineSecs); - }); - } - - /** - * Register an in-flight action button on the awaiting 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) => { - ev.preventDefault(); - try { onClick(); } catch (e) { console.error("awaiting action handler threw:", e); } - }; - } - - // ---------- GM_download wrappers ---------- /** @@ -497,7 +201,7 @@ 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"); + assert(typeof GM_download, "function", "GM_download must be a function"); assertTrue(typeof GM !== "undefined" && typeof GM.download === "function", "GM.download must exist"); }); @@ -535,7 +239,7 @@ const enableTool = true; conflictAction: "uniquify", }); const r = await withTimeout(promise, 10000, "blob: URL download"); - assertEq(r.kind, "load", "onload should fire"); + assert(r.kind, "load", "onload should fire"); } finally { URL.revokeObjectURL(blobUrl); } @@ -549,7 +253,7 @@ const enableTool = true; name, }); const r = await withTimeout(promise, 10000, "Blob object download"); - assertEq(r.kind, "load", "onload should fire"); + assert(r.kind, "load", "onload should fire"); }); // 5) File object as url — File extends Blob, should also work @@ -560,7 +264,7 @@ const enableTool = true; name, }); const r = await withTimeout(promise, 10000, "File object download"); - assertEq(r.kind, "load", "onload should fire"); + assert(r.kind, "load", "onload should fire"); }); // 6) data: URL @@ -572,7 +276,7 @@ const enableTool = true; name, }); const r = await withTimeout(promise, 10000, "data: URL download"); - assertEq(r.kind, "load", "onload should fire"); + assert(r.kind, "load", "onload should fire"); }); // 7) GM.download promise form @@ -733,7 +437,7 @@ const enableTool = 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"); + assert(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) { @@ -776,7 +480,7 @@ const enableTool = true; // Safety timeout setTimeout(resolve, 8000); }); - assertEq(onloadCalled, false, "onload must NOT fire"); + assert(onloadCalled, false, "onload must NOT fire"); assertTrue(!!errSeen, "onerror should fire"); }); @@ -795,7 +499,7 @@ const enableTool = true; setTimeout(resolve, 4000); }); } catch (e) { threw = e; } - assertEq(onloadCalled, false, "onload must NOT fire on bad URL"); + assert(onloadCalled, false, "onload must NOT fire on bad URL"); assertTrue(errSeen != null || threw != null, "either onerror fires or it throws"); }); @@ -814,7 +518,7 @@ const enableTool = true; setTimeout(resolve, 3000); }); } catch (e) { threw = e; } - assertEq(onloadCalled, false, "onload must NOT fire on empty URL"); + assert(onloadCalled, false, "onload must NOT fire on empty URL"); assertTrue(errSeen != null || threw != null, "either onerror fires or it throws"); }); @@ -1090,7 +794,7 @@ const enableTool = true; setQueue(names.slice(i + 1)); } setStatus("done"); - logLine(`Done. Summary — ✅ ${state.pass} ❌ ${state.fail} ⏭️ ${state.skip}`); + printSummary(); } finally { running = false; setAllButtonsDisabled(false); @@ -1123,4 +827,361 @@ const enableTool = true; setStatus("idle"); // No auto-start: GM_download writes to disk, so we wait for explicit user action. -})(); +})((() => { + // 跟测试对象无关的基础设施:DOM 构建、日志面板、计数器、断言函数、人工验收(awaiting bar)控制。 + + // ---------- Tiny 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; + } + + 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", + }; + } + + // Renders the small, fixed vocabulary of inline markup this harness's log + // lines use (, , ,
, entities) into real DOM
+  // nodes — no innerHTML/insertAdjacentHTML/DOMParser, so no HTML-string
+  // parsing ever touches the page (CSP-safe in the injected-script context).
+  const ENTITY_MAP = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", nbsp: " " };
+  function decodeEntities(s) {
+    return s.replace(/&(#39|amp|lt|gt|quot|nbsp);/g, (m, name) => ENTITY_MAP[name]);
+  }
+  function renderMarkup(container, markup) {
+    container.textContent = "";
+    const re = /<(\/?)(b|i|code|pre)(?:\s+style="([^"]*)")?>/g;
+    let last = 0, m;
+    const stack = [container];
+    while ((m = re.exec(markup))) {
+      const text = markup.slice(last, m.index);
+      if (text) stack[stack.length - 1].appendChild(document.createTextNode(decodeEntities(text)));
+      const [, closing, tagName, style] = m;
+      if (closing) {
+        if (stack.length > 1) stack.pop();
+      } else {
+        const el = document.createElement(tagName);
+        if (style) el.style.cssText = style;
+        stack[stack.length - 1].appendChild(el);
+        stack.push(el);
+      }
+      last = re.lastIndex;
+    }
+    const rest = markup.slice(last);
+    if (rest) stack[stack.length - 1].appendChild(document.createTextNode(decodeEntities(rest)));
+  }
+
+  // ---------- Panel DOM refs (populated by showResultPanel()) ----------
+  let panel, $log, $counts, $status, $queue, $prefix, $runTagLabel, $progressWrap, $progressLabel, $progressBar,
+    $manualButtons, $awaitingWrap, $awaitingLabel, $awaitingTimer, $awaitingPass, $awaitingFail,
+    $awaitingSkip, $awaitingAction;
+
+  function logLine(html, cls = "") {
+    const line = document.createElement("div");
+    line.style.cssText = "padding:6px 0;border-bottom:1px dashed #2a2a2a";
+    renderMarkup(line, 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}`;
+  }
+  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 printSummary() { + logLine(`Done. Summary — ✅ ${state.pass} ❌ ${state.fail} ⏭️ ${state.skip}`); + } + + 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 assert(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; + const t = setTimeout(() => { + if (done) return; + done = true; + reject(new Error(`timed out after ${ms}ms: ${label || ""}`)); + }, ms); + p.then((v) => { if (done) return; done = true; clearTimeout(t); resolve(v); }, + (e) => { if (done) return; done = true; clearTimeout(t); reject(e); }); + }); + } + + // ---------- Awaiting bar (manual-test verdict UI) ---------- + // 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. + let _verdictResolve = null; + let _verdictTimerId = null; + let _verdictDeadline = 0; + + function showAwaiting(label, deadlineSecs) { + renderMarkup($awaitingLabel, label); + $awaitingWrap.style.display = ""; + _verdictDeadline = performance.now() + deadlineSecs * 1000; + tickAwaitingTimer(); + if (_verdictTimerId) clearInterval(_verdictTimerId); + _verdictTimerId = setInterval(tickAwaitingTimer, 250); + } + function tickAwaitingTimer() { + const remaining = Math.max(0, Math.ceil((_verdictDeadline - performance.now()) / 1000)); + $awaitingTimer.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.textContent = ""; + $awaitingTimer.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; + } + function resolveVerdict(v) { + if (!_verdictResolve) return; + const r = _verdictResolve; + _verdictResolve = null; + hideAwaiting(); + r(v); + } + + /** + * 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). + * @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) { + return new Promise((resolve) => { + _verdictResolve = resolve; + showAwaiting(promptHtml, deadlineSecs); + }); + } + + /** + * Register an in-flight action button on the awaiting 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) => { + ev.preventDefault(); + try { onClick(); } catch (e) { console.error("awaiting action handler threw:", e); } + }; + } + + // ---------- Panel ---------- + // Builds the whole test-runner panel DOM tree, injects it into the page, + // wires up the parts of the UI that don't depend on this file's specific + // test suite (clear log, verdict buttons), and returns the handles the + // suite-specific code needs (start/setPrefix button wiring lives with the + // suite, since it depends on suite-specific settings and the runner). + function showResultPanel() { + 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" } }, ""), + h("button", { id: "setPrefix", style: btnStyle("#444") }, "Set prefix"), + h("span", { id: "runTagLabel", style: { opacity: .6, marginLeft: "auto", fontSize: "11.5px" } }, "") + ), + + // 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); + + $log = panel.querySelector("#log"); + $counts = panel.querySelector("#counts"); + $status = panel.querySelector("#status"); + $queue = panel.querySelector("#queue"); + $prefix = panel.querySelector("#prefix"); + $runTagLabel = panel.querySelector("#runTagLabel"); + $progressWrap = panel.querySelector("#progressWrap"); + $progressLabel = panel.querySelector("#progressLabel"); + $progressBar = panel.querySelector("#progressBar"); + $manualButtons = panel.querySelector("#manualButtons"); + $awaitingWrap = panel.querySelector("#awaitingWrap"); + $awaitingLabel = panel.querySelector("#awaitingLabel"); + $awaitingTimer = panel.querySelector("#awaitingTimer"); + $awaitingPass = panel.querySelector("#awaitingPass"); + $awaitingFail = panel.querySelector("#awaitingFail"); + $awaitingSkip = panel.querySelector("#awaitingSkip"); + $awaitingAction = panel.querySelector("#awaitingAction"); + + panel.querySelector("#clear").addEventListener("click", () => { + $log.textContent = ""; + state.pass = state.fail = state.skip = 0; + setCounts(); + setStatus("idle"); + setQueue([]); + hideProgress(); + }); + $awaitingPass.addEventListener("click", () => resolveVerdict({ verdict: "pass" })); + $awaitingFail.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" })); + + setCounts(); + + return { panel, $manualButtons, $prefix, $runTagLabel }; + } + + return { h, escapeHtml, fmtMs, btnStyle, logLine, pass, fail, skip, setStatus, setQueue, showProgress, updateProgress, hideProgress, assert, assertTrue, withTimeout, awaitVerdict, showAwaitingAction, hideAwaiting, printSummary, showResultPanel }; +})()); diff --git a/example/tests/gm_menu_test.js b/example/tests/gm_menu_test.js index 216015dfb..2a1b08e31 100644 --- a/example/tests/gm_menu_test.js +++ b/example/tests/gm_menu_test.js @@ -8,40 +8,11 @@ // @grant GM_unregisterMenuCommand // ==/UserScript== -(async function () { - 'use strict'; - +(async ({ waitActions, isInSubFrame, resolveNext }) => { const checkSubFrameIdSequence = false; const intervalChanging = false; - 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 { - return window.top !== window; - } catch { - return true; - } - } - if (intervalChanging) { // TM: 在打开菜单时,显示会不断改变 let i = 1000; @@ -78,13 +49,13 @@ const r01 = GM_registerMenuCommand("MenuReg abc-1", () => { console.log("abc-1"); - myResolve(); + resolveNext(); }, obj1); const r02 = GM_registerMenuCommand("MenuReg abc-2", () => { console.log("abc-2"); - myResolve(); + resolveNext(); }, obj1); console.log("abc-1 id === abc", r01 === "abc"); @@ -96,13 +67,13 @@ GM_registerMenuCommand("MenuReg abc-1", () => { console.log("abc-1.abd"); - myResolve(); + resolveNext(); }, { id: "abd" }); GM_registerMenuCommand("MenuReg abc-2", () => { console.log("abc-2.abe"); - myResolve(); + resolveNext(); }, { id: "abe" }); @@ -113,7 +84,7 @@ GM_registerMenuCommand("MenuReg abc-2", () => { console.log("abc-2.abf"); - myResolve(); + resolveNext(); }, { id: "abf", accessKey: "h" }); // there shall be only "MenuReg abc-1" and "MenuReg abc-2" in the menu. @@ -129,14 +100,14 @@ const p10 = GM_registerMenuCommand("MenuReg D-23", () => { console.log(110); - myResolve(); + resolveNext(); }, "b"); const p20 = GM_registerMenuCommand("MenuReg D-23", () => { console.log(120); - myResolve(); + resolveNext(); }, "b"); console.log("p10 === 1", p10 === 1); @@ -149,7 +120,7 @@ const p30 = GM_registerMenuCommand("MenuReg D-26", () => { console.log(130); - myResolve(); + resolveNext(); }, { id: "2" }); console.log("p30 === '2'", p30 === "2"); @@ -162,7 +133,7 @@ const p32 = GM_registerMenuCommand("MenuReg D-26", () => { console.log(210); - myResolve(); + resolveNext(); }, { id: 2 }); console.log("p32 === 2", p32 === 2); @@ -175,7 +146,7 @@ const p33 = GM_registerMenuCommand("MenuReg D-26", () => { console.log(220); - myResolve(); + resolveNext(); }, { id: 3 }); console.log("p33 === 3", p33 === 3); @@ -189,7 +160,7 @@ const p34 = GM_registerMenuCommand("MenuReg D-26", () => { console.log(230); - myResolve(); + resolveNext(); }, { id: "4" }); console.log("p34 === '4'", p34 === "4"); @@ -219,7 +190,40 @@ -})().finally(() => { +})((() => { + // 跟测试对象无关的基础设施:交互式等待(点击菜单以继续)相关的辅助函数。 + 'use strict'; + + 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 { + return window.top !== window; + } catch { + return true; + } + } + + const resolveNext = () => { myResolve(); }; + + return { waitActions, isInSubFrame, resolveNext }; +})()).finally(() => { console.log("finish"); }); - diff --git a/example/tests/gm_value_test.js b/example/tests/gm_value_test.js index d0fee5b33..3c3571bd6 100644 --- a/example/tests/gm_value_test.js +++ b/example/tests/gm_value_test.js @@ -12,9 +12,9 @@ // @run-at document-idle // ==/UserScript== -(function () { - 'use strict'; +'use strict'; +(async ({ escHtml, nowTime, buildLogLine, buildKvCard }) => { if (!location.search.includes('testGMAddValueChangeListener')) return; document.documentElement.appendChild(document.createElement("style")).textContent=`@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700,800&display=swap');`; @@ -53,29 +53,6 @@ const frameId = new URLSearchParams(location.search).get('frameId') || (isMain ? 'main' : 'unknown'); - /* ══════════════════════════════════════════════════════════ - HELPERS - ══════════════════════════════════════════════════════════ */ - function escHtml(s) { - return String(s).replace(/[&<>"']/g, ch => ({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - }[ch])); - } - - function fmtVal(v) { - return v === undefined - ? 'not set' - : escHtml(JSON.stringify(v)); - } - - function nowTime() { - return new Date().toLocaleTimeString('en-GB', { hour12: false }); - } - /* ══════════════════════════════════════════════════════════ MSG BUS ══════════════════════════════════════════════════════════ */ @@ -205,14 +182,7 @@ const logBox = iframeShadow.getElementById('iframe-log'); if (!logBox) return; - const line = document.createElement('div'); - line.className = 'log-line'; - line.innerHTML = ` - ${escHtml(nowTime())} - ${msg} - `; - - logBox.appendChild(line); + logBox.appendChild(buildLogLine(nowTime(), msg, type)); logBox.scrollTop = logBox.scrollHeight; } @@ -364,11 +334,22 @@ const shell = document.createElement('div'); shell.id = 'iframe-shell'; - shell.innerHTML = ` -
${escHtml(LABEL[frameId])}
-
Controlled by main frame dashboard ↑
-
- `; + + const titleDiv = document.createElement('div'); + titleDiv.className = 'iframe-title'; + titleDiv.textContent = LABEL[frameId]; + + const subtitleDiv = document.createElement('div'); + subtitleDiv.className = 'iframe-subtitle'; + subtitleDiv.textContent = 'Controlled by main frame dashboard ↑'; + + const logDiv = document.createElement('div'); + logDiv.id = 'iframe-log'; + logDiv.className = 'log-box'; + + shell.appendChild(titleDiv); + shell.appendChild(subtitleDiv); + shell.appendChild(logDiv); iframeShadow.appendChild(shell); } @@ -720,7 +701,10 @@ const topbar = document.createElement('div'); topbar.id = 'topbar'; - topbar.innerHTML = `⚙ GM_addValueChangeListener Test`; + const topbarTitle = document.createElement('span'); + topbarTitle.id = 'topbar-title'; + topbarTitle.textContent = '⚙ GM_addValueChangeListener Test'; + topbar.appendChild(topbarTitle); const closeBtn = document.createElement('button'); closeBtn.className = 'danger'; @@ -866,7 +850,7 @@ const clrB = makeBtn('✕ clear log', true); clrB.style.marginTop = '5px'; clrB.onclick = () => { - logBox.innerHTML = ''; + logBox.textContent = ''; state.logs[id] = []; }; card.appendChild(clrB); @@ -917,39 +901,20 @@ function mLog(msg, type = '') { const { logBox } = refs(); - const line = document.createElement('div'); - line.className = 'log-line'; - line.innerHTML = ` - ${nowTime()} - ${msg} - `; - - logBox.appendChild(line); + logBox.appendChild(buildLogLine(nowTime(), msg, type)); logBox.scrollTop = logBox.scrollHeight; } async function mRefreshKV() { const { kvTable, myKey: mk, accent } = refs(); - kvTable.innerHTML = ''; + kvTable.textContent = ''; for (const k of ALL_KEYS) { const v = await GM_getValue(k, undefined); // main frame const own = k === mk; - const card = document.createElement('div'); - card.className = 'kv-card'; - - if (own) card.style.borderColor = accent + '88'; - - card.innerHTML = ` -
${escHtml(k)}${own ? ' (mine)' : ''}
-
- ${fmtVal(v)} -
- `; - - kvTable.appendChild(card); + kvTable.appendChild(buildKvCard(k, v, own, accent)); } } @@ -1096,14 +1061,7 @@ const { logBox } = refs; - const line = document.createElement('div'); - line.className = 'log-line'; - line.innerHTML = ` - ${escHtml(entry.t || nowTime())} - ${entry.msg || ''} - `; - - logBox.appendChild(line); + logBox.appendChild(buildLogLine(entry.t || nowTime(), entry.msg || '', entry.type || '')); logBox.scrollTop = logBox.scrollHeight; } @@ -1113,25 +1071,13 @@ const { kvTable, myKey, accent } = refs; - kvTable.innerHTML = ''; + kvTable.textContent = ''; for (const k of ALL_KEYS) { const v = kvMap[k]; const own = k === myKey; - const card = document.createElement('div'); - card.className = 'kv-card'; - - if (own) card.style.borderColor = accent + '88'; - - card.innerHTML = ` -
${escHtml(k)}${own ? ' (mine)' : ''}
-
- ${fmtVal(v)} -
- `; - - kvTable.appendChild(card); + kvTable.appendChild(buildKvCard(k, v, own, accent)); } } @@ -1146,4 +1092,99 @@ dot.className = 'dot' + (activeKeys.has(k) ? ' on' : ''); } } -})(); +})((() => { + // 跟测试对象无关的基础设施:HTML 转义、值格式化、日志行/KV 卡片的 DOM 构建。 + // 用手写的极简标签渲染器(只认识 //,可带 style 属性)代替 + // innerHTML,避免任何 HTML 字符串解析进入页面(注入脚本上下文下的 CSP 要求)。 + + function escHtml(s) { + return String(s).replace(/[&<>"']/g, ch => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }[ch])); + } + + const ENTITY_MAP = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" }; + function decodeEntities(s) { + return s.replace(/&(#39|amp|lt|gt|quot);/g, (m, name) => ENTITY_MAP[name]); + } + + function appendInline(container, markup) { + const re = /<(\/?)(b|i|small)(?:\s+style="([^"]*)")?>/g; + let last = 0, m; + const stack = [container]; + while ((m = re.exec(markup))) { + const text = markup.slice(last, m.index); + if (text) stack[stack.length - 1].appendChild(document.createTextNode(decodeEntities(text))); + const [, closing, tagName, style] = m; + if (closing) { + if (stack.length > 1) stack.pop(); + } else { + const el = document.createElement(tagName); + if (style) el.style.cssText = style; + stack[stack.length - 1].appendChild(el); + stack.push(el); + } + last = re.lastIndex; + } + const rest = markup.slice(last); + if (rest) stack[stack.length - 1].appendChild(document.createTextNode(decodeEntities(rest))); + } + + function fmtVal(v) { + return v === undefined + ? 'not set' + : escHtml(JSON.stringify(v)); + } + + function nowTime() { + return new Date().toLocaleTimeString('en-GB', { hour12: false }); + } + + function buildLogLine(time, msg, type) { + const line = document.createElement('div'); + line.className = 'log-line'; + + const timeSpan = document.createElement('span'); + timeSpan.className = 'log-time'; + timeSpan.textContent = time; + + const msgSpan = document.createElement('span'); + msgSpan.className = `log-msg ${type || ''}`.trim(); + appendInline(msgSpan, msg); + + line.appendChild(timeSpan); + line.appendChild(msgSpan); + return line; + } + + function buildKvCard(k, v, own, accent) { + const card = document.createElement('div'); + card.className = 'kv-card'; + if (own) card.style.borderColor = accent + '88'; + + const keyDiv = document.createElement('div'); + keyDiv.className = 'kv-key'; + keyDiv.appendChild(document.createTextNode(k)); + if (own) { + keyDiv.appendChild(document.createTextNode(' ')); + const mineTag = document.createElement('i'); + mineTag.textContent = '(mine)'; + keyDiv.appendChild(mineTag); + } + + const valDiv = document.createElement('div'); + valDiv.className = 'kv-val'; + if (own) valDiv.style.cssText = `color:${accent};font-weight:700`; + appendInline(valDiv, fmtVal(v)); + + card.appendChild(keyDiv); + card.appendChild(valDiv); + return card; + } + + return { escHtml, appendInline, nowTime, fmtVal, buildLogLine, buildKvCard }; +})()); diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js index f473c66b0..b42b85369 100644 --- a/example/tests/gm_xhr_cookie_test.js +++ b/example/tests/gm_xhr_cookie_test.js @@ -9,9 +9,9 @@ // @noframes // ==/UserScript== -(async function () { - "use strict"; +"use strict"; +(async ({ test, assert, assertTrue, printSummary }) => { console.log( "%c=== GM_xmlhttpRequest cookie 覆盖测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;" @@ -19,47 +19,6 @@ 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({ @@ -517,29 +476,76 @@ } // ============ 输出测试结果 ============ - console.log( - "\n%c=== 测试完成 ===", - "color: blue; font-size: 16px; font-weight: bold;" - ); + printSummary(); +})((() => { + const testResults = { + passed: 0, + failed: 0, + total: 0, + }; - console.log( - `%c总计: ${testResults.total} | ` + - `通过: ${testResults.passed} | ` + - `失败: ${testResults.failed}`, - testResults.failed === 0 - ? "color: green; font-weight: bold;" - : "color: red; font-weight: bold;" - ); + 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}` + ); + } + } - if (testResults.failed === 0) { + function assertTrue(condition, message) { + if (!condition) { + throw new Error(message || "断言失败: 期望条件为真"); + } + } + + function printSummary() { console.log( - "%c🎉 所有测试通过!", - "color: green; font-size: 14px; font-weight: bold;" + "\n%c=== 测试完成 ===", + "color: blue; font-size: 16px; font-weight: bold;" ); - } else { + console.log( - "%c⚠️ 部分测试失败,请检查上面的错误信息", - "color: red; font-size: 14px; font-weight: bold;" + `%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;" + ); + } } -})(); + + return { test, assert, assertTrue, printSummary }; +})()); diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js index c54682c6f..4bae00f0b 100644 --- a/example/tests/gm_xhr_redirect_test.js +++ b/example/tests/gm_xhr_redirect_test.js @@ -11,82 +11,19 @@ // ==/UserScript== const enableTool = true; -(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"); +(async ({ assert, pass, fail, skip, state, setCounts, logLine, fmtMs, $status, verSpan, handlerSpan, startBtn, clearBtn }) => { + if (!enableTool) return; - panel.querySelector("#clear").addEventListener("click", () => { - $log.textContent = ""; + startBtn.addEventListener("click", runAll); + clearBtn.addEventListener("click", () => { + logLine.clear(); 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)}`); } + verSpan.textContent = GM.info?.script?.version ?? ""; + handlerSpan.textContent = `${GM.info?.scriptHandler} ${GM.info?.version}`; // ---------- Request helper ---------- function gmRequest(details, { abortAfterMs } = {}) { @@ -106,11 +43,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 +62,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"); + assert(200, res.status, "status 200"); + assert("234", res.response?.args?.testing, "response ok"); + assert("567", res.response?.args?.abc, "response ok"); + assert(`${HB}/get?testing=234&abc=567`, res.response?.url, "response ok"); + assert("ok", objectProps(res), "Object Props 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"); + assert(200, res.status, "status 200"); + assert("234", res.response?.args?.testing, "response ok"); + assert("567", res.response?.args?.abc, "response ok"); + assert(`${HB}/get?abc=567&testing=234`, res.response?.url, "response ok"); + assert("ok", objectProps(res), "Object Props OK"); }, }, { @@ -153,9 +85,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"); + assert(200, res.status, "status after redirect is 200"); + assert(target, res.finalUrl, "finalUrl is redirected target"); + assert("ok", objectProps(res), "Object Props OK"); }, }, { @@ -163,9 +95,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"); + assert(200, res.status, "status after redirect is 200"); + assert(target, res.finalUrl, "finalUrl is redirected target"); + assert("ok", objectProps(res), "Object Props OK"); }, }, { @@ -178,11 +110,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"); + assert("error", e?.kind, "error ok"); + assert(408, e?.res?.status, "statusCode ok"); + assert(true, !e?.res?.finalUrl, "!finalUrl ok"); + assert("", e?.res?.responseHeaders, "responseHeaders ok"); + assert("ok", objectProps(e?.res), "Object Props OK"); } }, }, @@ -194,10 +126,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"); + assert(301, res?.status, "status is 301"); + assert(url, res?.finalUrl, "finalUrl is original url"); + assert(true, typeof res?.responseHeaders === "string" && res?.responseHeaders !== "", "responseHeaders ok"); + assert("ok", objectProps(res), "Object Props OK"); }, }, ]; @@ -208,18 +140,16 @@ const enableTool = 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()}`); + logLine([{ text: "Starting GM_xmlhttpRequest test suite", bold: true }, { text: ` — ${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)}`); + logLine([{ text: "▶️ " }, { text: tName, bold: true }]); const t0 = performance.now(); try { await t.run(t.useFetch ? true : false); @@ -232,7 +162,7 @@ const enableTool = true; } $status.textContent = "Status: done"; - logLine(`Done. ✅ ${state.pass} ❌ ${state.fail} ⏳ ${state.skip}`); + logLine([{ text: "Done.", bold: true }, { text: ` ✅ ${state.pass} ❌ ${state.fail} ⏳ ${state.skip}` }]); } setTimeout(() => { @@ -241,4 +171,123 @@ const enableTool = true; runAll(); } }, 600); -})(); \ No newline at end of file +})((() => { + // ---------- Panel (DOM构建,避免innerHTML以兼容CSP) ---------- + const panel = document.createElement("div"); + panel.id = "gmxhr-test-panel"; + + const style = document.createElement("style"); + style.textContent = ` + #gmxhr-test-panel { + position:fixed; bottom:12px; right:12px; width:460px; max-height:70vh; + overflow:auto; z-index:2147483647; background:#111; color:#f5f5f5; + font:13px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; + border-radius:10px; box-shadow:0 12px 30px rgba(0,0,0,.4); border:1px solid #333; + } + #gmxhr-test-panel .hdr { + position:sticky; top:0; background:#181818; padding:10px 12px; + border-bottom:1px solid #333; display:flex; align-items:center; gap:8px; + } + #gmxhr-test-panel .hdr-info { flex:1 } + #gmxhr-test-panel button { + background:#2a6df1; color:#fff; border:0; padding:6px 10px; + border-radius:6px; cursor:pointer; + } + #gmxhr-test-panel #status { padding:6px 12px; border-bottom:1px solid #222; opacity:.9 } + #gmxhr-test-panel #log { padding:10px 12px } + #gmxhr-test-panel #log > div { padding:6px 0; border-bottom:1px dashed #2a2a2a } + #gmxhr-test-panel pre { white-space:pre-wrap; color:#bbb; margin:.5em 0 0 } + `; + panel.appendChild(style); + + const hdr = document.createElement("div"); + hdr.className = "hdr"; + + const hdrInfo = document.createElement("div"); + hdrInfo.className = "hdr-info"; + + const titleDiv = document.createElement("div"); + titleDiv.style.fontWeight = "500"; + titleDiv.appendChild(document.createTextNode("GM_xmlhttpRequest Test Harness ")); + const verSpan = document.createElement("span"); + verSpan.id = "ver"; + titleDiv.appendChild(verSpan); + hdrInfo.appendChild(titleDiv); + + const subRow = document.createElement("div"); + subRow.style.display = "flex"; + const handlerSpan = document.createElement("span"); + handlerSpan.id = "handler"; + const countsSpan = document.createElement("span"); + countsSpan.id = "counts"; + countsSpan.style.marginLeft = "auto"; + countsSpan.style.opacity = ".8"; + countsSpan.textContent = "…"; + subRow.appendChild(handlerSpan); + subRow.appendChild(countsSpan); + hdrInfo.appendChild(subRow); + + hdr.appendChild(hdrInfo); + + const startBtn = document.createElement("button"); + startBtn.id = "start"; + startBtn.textContent = "Run"; + const clearBtn = document.createElement("button"); + clearBtn.id = "clear"; + clearBtn.textContent = "Clear"; + hdr.appendChild(startBtn); + hdr.appendChild(clearBtn); + + panel.appendChild(hdr); + + const $status = document.createElement("div"); + $status.id = "status"; + $status.textContent = "Status: idle"; + panel.appendChild($status); + + const $log = document.createElement("div"); + $log.id = "log"; + panel.appendChild($log); + + document.documentElement.append(panel); + + // assert(expected, actual, message) - 比较两个值是否相等 + function assert(expected, actual, message) { + if (expected !== actual) { + throw new Error(message ? `${message}: expected ${expected}, got ${actual}` : `expected ${expected}, got ${actual}`); + } + } + + const state = { pass: 0, fail: 0, skip: 0 }; + function setCounts(p, f, s) { countsSpan.textContent = `✅ ${p} ❌ ${f} ⏳ ${s}`; } + + // logLine(parts, extra) - parts 为字符串或 { text, bold } 片段数组;extra 为附加的
 文本
+  function logLine(parts, extra) {
+    const el = document.createElement("div");
+    const segments = Array.isArray(parts) ? parts : [{ text: parts }];
+    segments.forEach(seg => {
+      if (seg.bold) {
+        const b = document.createElement("b");
+        b.textContent = seg.text;
+        el.appendChild(b);
+      } else {
+        el.appendChild(document.createTextNode(seg.text));
+      }
+    });
+    if (extra) {
+      const pre = document.createElement("pre");
+      pre.textContent = extra;
+      el.appendChild(pre);
+    }
+    $log.prepend(el);
+  }
+  logLine.clear = () => { $log.textContent = ""; };
+
+  function pass(msg) { state.pass++; setCounts(state.pass, state.fail, state.skip); logLine(`✅ ${msg}`); }
+  function fail(msg, extra) { state.fail++; setCounts(state.pass, state.fail, state.skip); logLine(`❌ ${msg}`, extra); }
+  function skip(msg) { state.skip++; setCounts(state.pass, state.fail, state.skip); logLine(`⏭️ ${msg}`); }
+
+  function fmtMs(ms) { return ms < 1000 ? `${ms | 0}ms` : `${(ms / 1000).toFixed(2)}s`; }
+
+  return { assert, pass, fail, skip, state, setCounts, logLine, fmtMs, $status, verSpan, handlerSpan, startBtn, clearBtn };
+})());
diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js
index 2cccdf56a..b9e7c73d9 100644
--- a/example/tests/gm_xhr_test.js
+++ b/example/tests/gm_xhr_test.js
@@ -44,378 +44,15 @@
   ✓ edge cases: huge headers trimmed? invalid method; invalid URL; missing @connect domain triggers onerror
 */
 
+"use strict";
+
 const enableTool = true;
-(function () {
-  "use strict";
+(async ({ escapeHtml, fmtMs, logLine, pass, fail, skip, setCounts, setStatus, setQueue, prettyStack, state, assert, assertTrue, assertDeepEq, resPrint, isFirefox, printSummary, showResultPanel }) => {
   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;
-  }
-
-  // value type helper
-  const typing = (x) => {
-    let t = x === null ? "null" : typeof x;
-    if (!x) t = `<${t}>`;
-    if (t === "object") {
-      try {
-        return x[Symbol.toStringTag] || "object";
-      } catch (e) {}
-    }
-    return t;
-  };
-
-  const statusCode = (response) => {
-    return (+response.readyState + +response.status / 1000).toFixed(3);
-  };
-
-  const resPrint = (r) => {
-    const a = statusCode(r);
-    const b1 = "response" in r ? typing(r.response) : "missing";
-    const b2 = "responseText" in r ? typing(r.responseText) : "missing";
-    const b3 = "responseXML" in r ? typing(r.responseXML) : "missing";
-    return `${a};r=${b1};t=${b2};x=${b3}`;
-  };
-
-  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([]);
-  });
+  const { panel } = showResultPanel();
   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] - ); - } - function gmRequest(details, { abortAfterMs } = {}) { return new Promise((resolve, reject) => { const t0 = performance.now(); @@ -465,11 +102,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText, decodedBase64, "responseText ok"); + assert(res.response, decodedBase64, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -482,11 +119,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText, decodedBase64, "responseText ok"); + assert(res.response, decodedBase64, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -499,11 +136,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText, decodedBase64, "responseText ok"); + assert(res.response, decodedBase64, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, @@ -517,11 +154,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText, decodedBase64, "responseText ok"); + assert(res.response, undefined, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -534,11 +171,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText, decodedBase64, "responseText ok"); + assert(res.response instanceof XMLDocument, true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -551,11 +188,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText, undefined, "responseText ok"); + assert(res.response instanceof ReadableStream, true, "response ok"); + assert(res.responseXML, undefined, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -568,11 +205,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText, decodedBase64, "responseText ok"); + assert(res.response instanceof ArrayBuffer, true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -585,11 +222,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText, decodedBase64, "responseText ok"); + assert(res.response instanceof Blob, true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -601,11 +238,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"); + assert(res.status, 200, "status 200"); + assert(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assert(`${res.response}`.includes('"code": 200'), true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -618,11 +255,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"); + assert(res.status, 200, "status 200"); + assert(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assert(`${res.response}`.includes('"code": 200'), true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -635,11 +272,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"); + assert(res.status, 200, "status 200"); + assert(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assert(`${res.response}`.includes('"code": 200'), true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -652,11 +289,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"); + assert(res.status, 200, "status 200"); + assert(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assert(typeof res.response === "object" && res.response?.code === 200, true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -669,11 +306,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"); + assert(res.status, 200, "status 200"); + assert(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assert(res.response instanceof XMLDocument, true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -686,11 +323,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText, undefined, "responseText ok"); + assert(res.response instanceof ReadableStream, true, "response ok"); + assert(res.responseXML, undefined, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -703,11 +340,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"); + assert(res.status, 200, "status 200"); + assert(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assert(res.response instanceof ArrayBuffer, true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -720,11 +357,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"); + assert(res.status, 200, "status 200"); + assert(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assert(res.response instanceof Blob, true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -736,11 +373,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assert(res.response, res.responseText, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -753,11 +390,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assert(res.response, res.responseText, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -770,11 +407,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assert(res.response, res.responseText, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -787,11 +424,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assert(res.response, undefined, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -804,11 +441,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assert(res.response instanceof XMLDocument, true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -821,11 +458,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText, undefined, "responseText ok"); + assert(res.response instanceof ReadableStream, true, "response ok"); + assert(res.responseXML, undefined, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -838,11 +475,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assert(res.response instanceof ArrayBuffer, true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -855,11 +492,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"); + assert(res.status, 200, "status 200"); + assert(res.responseText?.length >= 8 && res.responseText?.length <= 32, true, "responseText ok"); + assert(res.response instanceof Blob, true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -873,13 +510,13 @@ const enableTool = true; fetch, }); const body = JSON.parse(res.responseText); - assertEq(res.status, 200, "status 200"); + assert(res.status, 200, "status 200"); const q = getQueryObj(body); - assertEq(q.x, "1", "query args"); + assert(q.x, "1", "query args"); 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"); + assert(hdrs["X-Custom"] || hdrs["x-custom"], "Hello", "custom header echo"); + assert(res.finalUrl, url, "finalUrl matches"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -892,9 +529,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"); + assert(res.status, 200, "status after redirect is 200"); + assert(res.finalUrl, target, "finalUrl is redirected target"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -908,9 +545,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"); + assert(res.status, 200, "status after redirect is 200"); + assert(res.finalUrl, target, "finalUrl is redirected target"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -932,11 +569,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"); + assert(e?.kind, "error", "error ok"); + assert(e?.res?.status, 408, "statusCode ok"); + assert(!e?.res?.finalUrl, true, "!finalUrl ok"); + assert(e?.res?.responseHeaders, "", "responseHeaders ok"); + assert(objectProps(e?.res), "ok", "Object Props OK"); } }, }, @@ -955,10 +592,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"); + assert(res?.status, 301, "status is 301"); + assert(res?.finalUrl, url, "finalUrl is original url"); + assert(typeof res?.responseHeaders === "string" && res?.responseHeaders !== "", true, "responseHeaders ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -973,10 +610,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"); + assert(res.status, 200); + assert((body.form || {}).a, "1", "form a"); + assert((body.form || {}).b, "two", "form b"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -991,9 +628,9 @@ const enableTool = true; fetch, }); const body = JSON.parse(res.responseText); - assertEq(res.status, 200); + assert(res.status, 200); assertDeepEq(body.json, payload, "JSON echo matches"); - assertEq(objectProps(res), "ok", "Object Props OK"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1008,9 +645,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"); + assert(res.status, 200); + assertTrue(body.data && body.data.length > 0, "server received some data"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1027,11 +664,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"); + assert(res.status, 200); + assertTrue(res.response instanceof ArrayBuffer, "arraybuffer present"); + assert(res.response.byteLength, size, "byte length matches"); + assertTrue(progressCounter >= 1, "progressCounter >= 1"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1049,12 +686,12 @@ const enableTool = true; }, fetch, }); - assertEq(res.status, 200); - assert(res.response instanceof Blob, "blob present"); + assert(res.status, 200); + assertTrue(res.response instanceof Blob, "blob present"); 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"); + assert(buf.byteLength, size, "byte length matches"); + assertTrue(progressCounter >= 1, "progressCounter >= 1"); + assert(objectProps(res), "ok", "Object Props OK"); // Do not assert image MIME; httpbun returns octet-stream here. }, }, @@ -1068,10 +705,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"); + assert(res.status, 200); + assertTrue(res.response && typeof res.response === "object", "parsed JSON object"); + assertTrue(res.response.origin, "has JSON fields"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1083,10 +720,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"); + assert(res.status, 200); + assertTrue(res.response instanceof Document, "xml present"); + assertTrue(res.responseXML !== null, "xml OK"); + assertTrue(!!res.responseXML.querySelector("test-123"), "xml content ok"); }, }, { @@ -1098,10 +735,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"); + assert(res.status, 200); + assertTrue(res.response instanceof Document, "xml present"); + assertTrue(res.responseXML !== null, "xml OK"); + assertTrue(!!res.responseXML.querySelector("parsererror"), "xml content ok"); }, }, { @@ -1113,9 +750,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"); + assert(res.status, 200); + assertTrue(typeof res.responseText === "string" && res.responseText.length > 0, "responseText available"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1130,7 +767,7 @@ const enableTool = true; }); throw new Error("Expected timeout, got load"); } catch (e) { - assertEq(e.kind, "timeout", "timeout path taken"); + assert(e.kind, "timeout", "timeout path taken"); } }, }, @@ -1177,7 +814,7 @@ const enableTool = true; url: `${HB}/get`, }); assertDeepEq(normal.events, ["onload", "onloadend"], "normal fires onload then onloadend"); - assertEq(normal.response.status, 200, "normal onloadend status 200"); + assert(normal.response.status, 200, "normal onloadend status 200"); const timeout = await runCase({ url: `${HB}/delay/5`, @@ -1235,12 +872,12 @@ const enableTool = true; fetch, }); }); - assertEq(res.status, 200); - assert(progressEvents >= 4, "received at least 4 progress events"); + assert(res.status, 200); + assertTrue(progressEvents >= 4, "received at least 4 progress events"); // `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"); + assertTrue(fetch ? lastLoaded > 0 : lastLoaded >= 0, "progress loaded captured"); + assertTrue(!response, "no response"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1279,12 +916,12 @@ const enableTool = true; fetch, }); }); - assertEq(res.status, 200); - assert(progressEvents >= 4, "received at least 4 progress events"); + assert(res.status, 200); + assertTrue(progressEvents >= 4, "received at least 4 progress events"); // `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"); + assertTrue(fetch ? lastLoaded > 0 : lastLoaded >= 0, "progress loaded captured"); + assertTrue(response instanceof ReadableStream && typeof response.getReader === "function", "response"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1295,10 +932,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"); + assert(res.status, 200); + assertTrue((res.responseText || "")?.length > 0, "body for HEAD"); + assertTrue(typeof res.responseHeaders === "string", "response headers present"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1309,10 +946,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"); + assert(res.status, 200); + assert(res.responseText || "", "", "no body for HEAD"); + assertTrue(typeof res.responseHeaders === "string", "response headers present"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1324,8 +961,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"); + assertTrue(res.status === 200 || res.status === 204, "200/204 on OPTIONS"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1336,10 +973,10 @@ const enableTool = true; url: `${HB}/delete`, fetch, }); - assertEq(res.status, 200); + assert(res.status, 200); const body = JSON.parse(res.responseText); - assertEq(body.method, "DELETE", "server saw DELETE"); - assertEq(objectProps(res), "ok", "Object Props OK"); + assert(body.method, "DELETE", "server saw DELETE"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1362,11 +999,11 @@ const enableTool = true; url: `${HB}/cookies`, fetch, }); - assertEq(res.status, 200); + assert(res.status, 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"); + assert(cookieABC, "123", "cookie abc=123"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1381,8 +1018,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"); + assertTrue(!`${cookies}`.includes("abc=123"), "no Cookie header when anonymous"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1396,8 +1033,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"); + assertTrue(`${cookies}`.includes("abc=123"), "Cookie header"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1436,8 +1073,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"); + assertTrue(!cookies, "no Cookie header when anonymous"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1464,11 +1101,11 @@ const enableTool = true; password: pass, fetch, }); - assertEq(res.status, 200); + assert(res.status, 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"); + assert(body.authenticated, true, "authenticated true"); + assert(body.user, "user", "user echoed"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1479,8 +1116,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"); + assert(res.status, 418, "418 I'm a teapot"); + assert(objectProps(res), "ok", "Object Props OK"); // Still triggers onload, not onerror }, }, @@ -1493,8 +1130,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"); + assertTrue([200, 405].includes(res.status), "200 or 405 depending on server handling"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1509,22 +1146,22 @@ 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( + assert(e.kind, "error", "onerror path taken"); + assertTrue(e.res, "e.res exists"); + assert(e.res.status, 0, "status 0"); + assert(e.res.statusText, "", 'statusText ""'); + assert(e.res.finalUrl, undefined, "finalUrl undefined"); + assert(e.res.readyState, 4, "readyState DONE"); + assert(!e.res.response, true, "!response ok"); + assert(e.res.responseText, "", 'responseText ""'); + assert(e.res.responseXML, undefined, "responseXML undefined"); + assert(typeof (e.res.error || undefined), "string", "error set"); + assert( `${e.res.error}`.includes(`Refused to connect to "https://example.org/": `), true, "Refused to connect to ..." ); - assertEq(objectProps(e.res), "ok", "Object Props OK"); + assert(objectProps(e.res), "ok", "Object Props OK"); } }, }, @@ -1539,22 +1176,22 @@ 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( + assert(e.kind, "error", "onerror path taken"); + assertTrue(e.res, "e.res exists"); + assert(e.res.status, 0, "status 0"); + assert(e.res.statusText, "", 'statusText ""'); + assert(e.res.finalUrl, undefined, "finalUrl undefined"); + assert(e.res.readyState, 4, "readyState DONE"); + assert(!e.res.response, true, "!response ok"); + assert(e.res.responseText, "", 'responseText ""'); + assert(e.res.responseXML, undefined, "responseXML undefined"); + assert(typeof (e.res.error || undefined), "string", "error set"); + assert( `${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"); + assert(objectProps(e.res), "ok", "Object Props OK"); } }, }, @@ -1569,13 +1206,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"); + assert(e.kind, "error", "onerror path taken"); + assertTrue(e.res, "e.res exists"); + assert(!e.res.response, true, "!response ok"); + assert(e.res.responseXML, undefined, "responseXML undefined"); + assert(e.res.responseHeaders, "", 'responseHeaders ""'); + assert(e.res.readyState, 4, "readyState 4"); + assert(objectProps(e.res), "ok", "Object Props OK"); } }, }, @@ -1596,7 +1233,7 @@ const enableTool = true; ]); throw new Error("Expected abort, got load"); } catch (e) { - assertEq(e.kind, "abort", "abort path taken"); + assert(e.kind, "abort", "abort path taken"); } }, }, @@ -1611,11 +1248,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"); + assert(res.status, 200, "status 200"); + assert(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assert(typeof res.response === "object" && res.response?.code === 200, true, "response ok"); + assert(res.responseXML instanceof XMLDocument, true, "responseXML ok"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1632,12 +1269,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"); + assert(res.status, 200, "status 200"); + assert(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assert(typeof res.response === "object" && res.response?.code === 200, true, "response ok"); + assert(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"); + assert(objectProps(res), "ok", "Object Props OK"); }, }, { @@ -1725,7 +1362,7 @@ const enableTool = true; ); const resultList = [...resultSet]; if (!fetch) { - assertEq(progressCount >= 2, true, "progressCount ok"); + assert(progressCount >= 2, true, "progressCount ok"); assertDeepEq( resultList, [ @@ -1741,7 +1378,7 @@ const enableTool = true; "standard-type GMXhr OK" ); } else { - assertEq(progressCount >= 2, true, "progressCount ok"); + assert(progressCount >= 2, true, "progressCount ok"); assertDeepEq( resultList, [ @@ -1789,7 +1426,7 @@ const enableTool = true; ); const resultList = [...resultSet]; if (!fetch) { - assertEq(progressCount >= 2, true, "progressCount ok"); + assert(progressCount >= 2, true, "progressCount ok"); assertDeepEq( resultList, [ @@ -1805,7 +1442,7 @@ const enableTool = true; "standard-type GMXhr OK" ); } else { - assertEq(progressCount >= 2, true, "progressCount ok"); + assert(progressCount >= 2, true, "progressCount ok"); assertDeepEq( resultList, [ @@ -1851,14 +1488,14 @@ const enableTool = true; }) ); const headers = resultHeaders; - assertEq(headers.get("content-type"), "application/json; charset=utf-8", "content-type ok"); - assertEq( + assert(headers.get("content-type"), "application/json; charset=utf-8", "content-type ok"); + assert( 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"); + assert(headers.get("cross-origin-opener-policy"), "same-origin", "cross-origin-opener-policy ok"); + assert(headers.get("content-encoding") !== "deflate", true, "content-encoding ok"); }, }, { @@ -1894,14 +1531,14 @@ const enableTool = true; }) ); const headers = resultHeaders; - assertEq(headers.get("content-type"), "application/json; charset=utf-8", "content-type ok"); - assertEq( + assert(headers.get("content-type"), "application/json; charset=utf-8", "content-type ok"); + assert( 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( + assert(headers.get("cross-origin-opener-policy"), "same-origin", "cross-origin-opener-policy ok"); + assert( headers.get("content-encoding") === "deflate" || headers.get("content-encoding") === null, true, "content-encoding ok" @@ -1917,31 +1554,424 @@ 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"); + assert(res.status, 200, "status 200"); + assert(typeof res.responseHeaders, "string", "responseHeaders is string"); + assert(res.responseHeaders.trim() === res.responseHeaders, true, "no extra space"); // 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`); + assertTrue(lines[i].length > 0, `header line ${i} present`); + } + assert(objectProps(res), "ok", "Object Props OK"); + }, + }, + ]; + + const tests = [ + ...basicTests, + ...basicTests.map((item) => { + return { ...item, useFetch: true }; + }), + ]; + + + function getHeader(headersStr, key) { + const lines = (headersStr || "").split(/\r?\n/); + const line = lines.find((l) => l.toLowerCase().startsWith(key.toLowerCase() + ":")); + return line ? line.split(":").slice(1).join(":").trim() : ""; + } + function objectProps(o) { + if (!o || typeof o !== "object") return "not an object"; + let z, oD, zD; + try { + z = Object.assign({}, o); + } catch { + return "Object.assign failed"; + } + // accept null / "" / undefined for normal/failed/fetch_normal/fetch_failed XHR + // non-empty text (still primitive) can be also accepted. (common in xhr error case) + if (typeof (z.response ?? "") !== "string") return "non-primitive response value exposed"; + if (typeof (z.responseText ?? "") !== "string") return "non-primitive responseText value exposed"; + if (typeof (z.responseXML ?? "") !== "string") return "non-primitive responseXML value exposed"; + try { + oD = JSON.stringify(o); + } catch { + return "JSON.stringify failed"; + } + try { + zD = JSON.stringify(z); + } catch { + return "JSON.stringify failed"; + } + if (oD !== zD) return "Object Props Failed"; + 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)); + } + } + + setStatus("done"); + printSummary(); + } + + // 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); +})((() => { + // 跟测试对象无关的基础设施:DOM 构建、日志面板、计数器、断言函数、堆栈美化打印。 + + // ---------- Small DOM helper ---------- + // ---------- 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; + } + + // value type helper + const typing = (x) => { + let t = x === null ? "null" : typeof x; + if (!x) t = `<${t}>`; + if (t === "object") { + try { + return x[Symbol.toStringTag] || "object"; + } catch (e) {} + } + return t; + }; + + const statusCode = (response) => { + return (+response.readyState + +response.status / 1000).toFixed(3); + }; + + const resPrint = (r) => { + const a = statusCode(r); + const b1 = "response" in r ? typing(r.response) : "missing"; + const b2 = "responseText" in r ? typing(r.responseText) : "missing"; + const b3 = "responseXML" in r ? typing(r.responseXML) : "missing"; + return `${a};r=${b1};t=${b2};x=${b3}`; + }; + + const isFirefox = typeof mozInnerScreenX === "number"; + + // ---------- Test Panel ---------- + + // Renders the small, fixed vocabulary of inline markup this harness's log + // lines use (,
, entities) into real DOM nodes — no
+  // innerHTML/insertAdjacentHTML/DOMParser, so no HTML-string parsing ever
+  // touches the page (CSP-safe in the injected-script context).
+  const ENTITY_MAP = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'" };
+  function decodeEntities(s) {
+    return s.replace(/&(#39|amp|lt|gt|quot);/g, (m, name) => ENTITY_MAP[name]);
+  }
+  function renderMarkup(container, markup) {
+    container.textContent = "";
+    const re = /<(\/?)(b|pre)(?:\s+style="([^"]*)")?>/g;
+    let last = 0, m;
+    const stack = [container];
+    while ((m = re.exec(markup))) {
+      const text = markup.slice(last, m.index);
+      if (text) stack[stack.length - 1].appendChild(document.createTextNode(decodeEntities(text)));
+      const [, closing, tagName, style] = m;
+      if (closing) {
+        if (stack.length > 1) stack.pop();
+      } else {
+        const el = document.createElement(tagName);
+        if (style) el.style.cssText = style;
+        stack[stack.length - 1].appendChild(el);
+        stack.push(el);
+      }
+      last = re.lastIndex;
+    }
+    const rest = markup.slice(last);
+    if (rest) stack[stack.length - 1].appendChild(document.createTextNode(decodeEntities(rest)));
+  }
+
+  // ---------- Panel DOM refs (populated by showResultPanel()) ----------
+  let panel, $log, $counts, $status, $queue;
+
+
+  function logLine(html, cls = "") {
+    const line = h("div", { style: { padding: "6px 0", borderBottom: "1px dashed #2a2a2a" } });
+    renderMarkup(line, 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;
         }
-        assertEq(objectProps(res), "ok", "Object Props OK");
-      },
-    },
-  ];
+      });
+    }
 
-  const tests = [
-    ...basicTests,
-    ...basicTests.map((item) => {
-      return { ...item, useFetch: true };
-    }),
-  ];
+    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] + ); + } // ---------- Assertion utils ---------- - function assert(condition, msg) { + function assertTrue(condition, msg) { if (!condition) throw new Error(msg || "assertion failed"); } - function assertEq(a, b, msg) { + function assert(a, b, msg) { if (a !== b) throw new Error(msg ? `${msg}: expected ${b}, got ${a}` : `expected ${b}, got ${a}`); } function assertDeepEq(a, b, msg) { @@ -1949,81 +1979,110 @@ const enableTool = true; 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() + ":")); - return line ? line.split(":").slice(1).join(":").trim() : ""; + + function fmtMs(ms) { + return ms < 1000 ? `${ms | 0}ms` : `${(ms / 1000).toFixed(2)}s`; } - function objectProps(o) { - if (!o || typeof o !== "object") return "not an object"; - let z, oD, zD; - try { - z = Object.assign({}, o); - } catch { - return "Object.assign failed"; - } - // accept null / "" / undefined for normal/failed/fetch_normal/fetch_failed XHR - // non-empty text (still primitive) can be also accepted. (common in xhr error case) - if (typeof (z.response ?? "") !== "string") return "non-primitive response value exposed"; - if (typeof (z.responseText ?? "") !== "string") return "non-primitive responseText value exposed"; - if (typeof (z.responseXML ?? "") !== "string") return "non-primitive responseXML value exposed"; - try { - oD = JSON.stringify(o); - } catch { - return "JSON.stringify failed"; - } - try { - zD = JSON.stringify(z); - } catch { - return "JSON.stringify failed"; - } - if (oD !== zD) return "Object Props Failed"; - return "ok"; + + function printSummary() { + logLine(`Done. Summary — ✅ ${state.pass} ❌ ${state.fail} ⏳ ${state.skip}`); } - // ---------- 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()}`); + // ---------- Panel ---------- + // Builds the whole test-runner panel DOM tree and injects it into the page. + // Wiring the #start button (which needs runAll, a suite-specific function) + // is left to the caller — only the parts that don't depend on this file's + // specific test suite live here. + function showResultPanel() { + 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); - 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)); - } + function btn() { + return { + background: "#2a6df1", + color: "white", + border: "0", + padding: "6px 10px", + borderRadius: "6px", + cursor: "pointer", + }; } - setStatus("done"); - logLine(`Done. Summary — ✅ ${state.pass} ❌ ${state.fail} ⏳ ${state.skip}`); - } + $log = panel.querySelector("#log"); + $counts = panel.querySelector("#counts"); + $status = panel.querySelector("#status"); + $queue = panel.querySelector("#queue"); + panel.querySelector("#clear").addEventListener("click", () => { + $log.textContent = ""; + setCounts(0, 0, 0); + setStatus("idle"); + setQueue([]); + }); - function fmtMs(ms) { - return ms < 1000 ? `${ms | 0}ms` : `${(ms / 1000).toFixed(2)}s`; + return { panel }; } - // 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); -})(); + return { escapeHtml, fmtMs, logLine, pass, fail, skip, setCounts, setStatus, setQueue, prettyStack, state, assert, assertTrue, assertDeepEq, resPrint, isFirefox, printSummary, showResultPanel }; +})()); diff --git a/example/tests/inject_content_test.js b/example/tests/inject_content_test.js index e34dd5805..16ef13bc7 100644 --- a/example/tests/inject_content_test.js +++ b/example/tests/inject_content_test.js @@ -17,48 +17,9 @@ // @run-at document-start // ==/UserScript== -(async function () { - "use strict"; - +(async ({ test, assert, assertTrue, printSummary }) => { console.log("%c=== Content环境 GM API 测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); - 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;"); @@ -157,15 +118,59 @@ }); // ============ 输出测试结果 ============ - 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;"); + printSummary(); +})((() => { + 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 || "断言失败: 期望条件为真"); + } + } + + // printSummary() - 输出测试结果汇总 + function printSummary() { + 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;"); + } + } + + return { test, assert, assertTrue, printSummary }; +})()); diff --git a/example/tests/sandbox_test.js b/example/tests/sandbox_test.js index 405aae470..43822a565 100644 --- a/example/tests/sandbox_test.js +++ b/example/tests/sandbox_test.js @@ -27,54 +27,18 @@ const mpt = document.body.appendChild(document.createElement("test-element-1002" mpt.id = "test-element-1002"; mpt.name = "test-element-1002"; -(async function () { +(async (helpers) => { "use strict"; + const { test, assert, assertNotSame, assertTrue, section, printSummary } = helpers; + const markerPrefix = `__scriptcat_sandbox_${Date.now()}_${Math.random().toString(36).slice(2)}`; - const testResults = { - passed: 0, - failed: 0, - total: 0, - }; console.log( "%c=== 半沙盒环境测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;", ); - function formatValue(value) { - if (value === window) return "[sandbox window]"; - if (value === unsafeWindow) return "[unsafeWindow]"; - if (value === document) return "[document]"; - if (value && value.nodeType) return `[node ${value.nodeName}]`; - if (typeof value === "function") - return `[function ${value.name || "anonymous"}]`; - if (typeof value === "symbol") return value.toString(); - try { - return JSON.stringify(value); - } catch { - return String(value); - } - } - - function assertSame(expected, actual, message) { - if (!Object.is(expected, actual)) { - throw new Error( - `${message} - 期望 ${formatValue(expected)}, 实际 ${formatValue(actual)}`, - ); - } - } - - function assertNotSame(unexpected, actual, message) { - if (Object.is(unexpected, actual)) { - throw new Error(`${message} - 不应等于 ${formatValue(unexpected)}`); - } - } - - function assertTrue(condition, message) { - if (!condition) throw new Error(message || "断言失败: 条件不为真"); - } - function assertThrowsOrKeepsValue(assign, read, expected, message) { let threw = false; try { @@ -82,31 +46,13 @@ mpt.name = "test-element-1002"; } catch { threw = true; } - assertSame( + assert( expected, read(), `${message}${threw ? "(赋值抛出,值保持不变)" : ""}`, ); } - 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)); } @@ -151,28 +97,28 @@ mpt.name = "test-element-1002"; section("沙盒全局身份"); await test("检测全局 testVar1001 会否跳出沙盒", () => { - assertSame(undefined, window["testVar1001"], "全局 testVar1001 不应跳出沙盒"); - assertSame(undefined, unsafeWindow["testVar1001"], "全局 testVar1001 不应跳出沙盒"); + assert(undefined, window["testVar1001"], "全局 testVar1001 不应跳出沙盒"); + assert(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"); + assert("test-element-1002", unsafeWindow["test-element-1002"]?.id, "全局 test-element-1002"); + assert(undefined, window["test-element-1002"]?.id, "半沙盒无法检测 test-element-1002"); }); await test("window/self/globalThis/top/parent/frames 均指向沙盒对象", () => { - assertSame("object", typeof unsafeWindow, "unsafeWindow 应存在"); + assert("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( + assert(window, self, "self 应指向沙盒 window"); + assert(window, globalThis, "globalThis 应指向沙盒 window"); + assert(window, top, "top 应指向沙盒 window"); + assert(window, parent, "parent 应指向沙盒 window"); + assert(window, frames, "frames 应指向沙盒 window"); + assert( "[object Window]", Object.prototype.toString.call(window), "沙盒 window 应保持 Window 标记", @@ -180,22 +126,22 @@ mpt.name = "test-element-1002"; }); await test("沙盒 window 使用空原型,但保留页面 Window 外观 (Issue #962)", () => { - assertSame( + assert( null, Object.getPrototypeOf(window), "沙盒 window 的原型应为空,避免 Object.prototype 污染穿透", ); - assertSame( + assert( unsafeWindow.constructor, window.constructor, "constructor 应与页面 Window 构造器一致", ); - assertSame( + assert( unsafeWindow.__proto__, window.__proto__, "__proto__ 应暴露页面 Window 原型用于兼容", ); - assertSame( + assert( false, window instanceof unsafeWindow.constructor, "沙盒 window 不应是真实 Window 实例", @@ -203,13 +149,13 @@ mpt.name = "test-element-1002"; }); await test("页面 DOM getter 返回真实页面对象", () => { - assertSame(unsafeWindow.document, document, "document 应是页面 document"); - assertSame( + assert(unsafeWindow.document, document, "document 应是页面 document"); + assert( unsafeWindow.location.href, location.href, "location 应读取页面地址", ); - assertSame( + assert( unsafeWindow.document.documentElement, document.documentElement, "DOM 节点身份应与页面一致", @@ -221,16 +167,16 @@ mpt.name = "test-element-1002"; () => { const key = `${markerPrefix}_page_global`; unsafeWindow[key] = "page-value"; - assertSame( + assert( "page-value", unsafeWindow[key], "页面变量应写入 unsafeWindow", ); - assertSame(undefined, window[key], "页面变量不应出现在沙盒 window"); + assert(undefined, window[key], "页面变量不应出现在沙盒 window"); window[key] = "sandbox-value"; - assertSame("sandbox-value", window[key], "沙盒变量应写入沙盒 window"); - assertSame("page-value", unsafeWindow[key], "沙盒变量不应覆盖页面变量"); + assert("sandbox-value", window[key], "沙盒变量应写入沙盒 window"); + assert("page-value", unsafeWindow[key], "沙盒变量不应覆盖页面变量"); }, () => { delete window[`${markerPrefix}_page_global`]; @@ -246,12 +192,12 @@ mpt.name = "test-element-1002"; div.id = id; document.body.appendChild(div); - assertSame( + assert( div, unsafeWindow[id], "页面 window 应可通过 named property 访问元素", ); - assertSame( + assert( undefined, window[id], "沙盒 window 不应通过 named property 访问页面元素", @@ -268,16 +214,16 @@ mpt.name = "test-element-1002"; const key = `${markerPrefix}_delete_page_global`; unsafeWindow[key] = "page-value"; - assertSame(undefined, window[key], "页面变量不应自动出现在沙盒 window"); + assert(undefined, window[key], "页面变量不应自动出现在沙盒 window"); window[key] = "sandbox-value"; - assertSame("sandbox-value", window[key], "沙盒变量应存在"); - assertSame("page-value", unsafeWindow[key], "页面变量应保持存在"); + assert("sandbox-value", window[key], "沙盒变量应存在"); + assert("page-value", unsafeWindow[key], "页面变量应保持存在"); delete window[key]; - assertSame(undefined, window[key], "删除后沙盒变量应消失"); - assertSame( + assert(undefined, window[key], "删除后沙盒变量应消失"); + assert( "page-value", unsafeWindow[key], "删除沙盒变量不应删除页面变量", @@ -297,8 +243,8 @@ mpt.name = "test-element-1002"; unsafeWindow[key] = "page-value"; window[key] = "sandbox-value"; - assertSame("sandbox-value", window[key], "裸变量应读取沙盒值"); - assertSame("page-value", unsafeWindow[key], "页面变量应保持存在"); + assert("sandbox-value", window[key], "裸变量应读取沙盒值"); + assert("page-value", unsafeWindow[key], "页面变量应保持存在"); try { Function(`return delete ${key};`)(); // 半沙盒在页面执行 @@ -307,8 +253,8 @@ mpt.name = "test-element-1002"; delete unsafeWindow[key]; // fallback } - assertSame(undefined, unsafeWindow[key], "裸 delete 后页面变量应消失"); - assertSame("sandbox-value", window[key], "裸 delete 后沙盒变量不应消失"); + assert(undefined, unsafeWindow[key], "裸 delete 后页面变量应消失"); + assert("sandbox-value", window[key], "裸 delete 后沙盒变量不应消失"); }, () => { window[`${markerPrefix}_delete_bare_page_global`] = undefined; @@ -322,13 +268,13 @@ mpt.name = "test-element-1002"; () => { const key = `${markerPrefix}_polluted`; Object.prototype[key] = "polluted-value"; - assertSame("polluted-value", {}[key], "测试前应确认原型污染已生效"); - assertSame( + assert("polluted-value", {}[key], "测试前应确认原型污染已生效"); + assert( undefined, window[key], "沙盒 window 不应读取 Object.prototype 上的污染字段", ); - assertSame( + assert( false, key in window, "污染字段不应出现在沙盒 window 的原型链", @@ -349,9 +295,9 @@ mpt.name = "test-element-1002"; } } - assertSame(undefined, window.define, "define 应被沙盒置为 undefined"); - assertSame(undefined, window.module, "module 应被沙盒置为 undefined"); - assertSame(undefined, window.exports, "exports 应被沙盒置为 undefined"); + assert(undefined, window.define, "define 应被沙盒置为 undefined"); + assert(undefined, window.module, "module 应被沙盒置为 undefined"); + assert(undefined, window.exports, "exports 应被沙盒置为 undefined"); for (const key of [ "runFlag", @@ -369,7 +315,7 @@ mpt.name = "test-element-1002"; "setInvalidContext", "isInvalidContext", ]) { - assertSame(undefined, window[key], `${key} 不应暴露到沙盒 window`); + assert(undefined, window[key], `${key} 不应暴露到沙盒 window`); } }, (() => { @@ -384,8 +330,8 @@ mpt.name = "test-element-1002"; console, "沙盒 console 应与页面 console 不是同一个对象", ); - assertSame("function", typeof console.log, "console.log 应可调用"); - assertSame("function", typeof console.error, "console.error 应可调用"); + assert("function", typeof console.log, "console.log 应可调用"); + assert("function", typeof console.error, "console.error 应可调用"); }); section("原生函数与事件代理"); @@ -401,7 +347,7 @@ mpt.name = "test-element-1002"; resolve(); }, 0); }); - assertSame(true, called, "裸调用 setTimeout 应正常执行"); + assert(true, called, "裸调用 setTimeout 应正常执行"); let intervalCount = 0; await new Promise((resolve) => { @@ -411,7 +357,7 @@ mpt.name = "test-element-1002"; resolve(); }, 0); }); - assertSame(1, intervalCount, "裸调用 setInterval 应正常执行"); + assert(1, intervalCount, "裸调用 setInterval 应正常执行"); const rawAddEventListener = addEventListener; const rawRemoveEventListener = removeEventListener; @@ -423,11 +369,11 @@ mpt.name = "test-element-1002"; rawAddEventListener(eventName, handler); unsafeWindow.dispatchEvent(new Event(eventName)); rawRemoveEventListener(eventName, handler); - assertSame(1, count, "裸调用 addEventListener 应绑定到页面 window"); + assert(1, count, "裸调用 addEventListener 应绑定到页面 window"); if (typeof fetch === "function") { const rawFetch = fetch; - assertSame("function", typeof rawFetch, "fetch 应可读取为裸函数"); + assert("function", typeof rawFetch, "fetch 应可读取为裸函数"); } }); @@ -446,7 +392,7 @@ mpt.name = "test-element-1002"; unsafeWindow.dispatchEvent(new Event(eventName)); rawRemoveEventListener(eventName, handler); - assertSame( + assert( 1, count, "window.addEventListener 取出后调用应绑定到页面 window", @@ -465,14 +411,14 @@ mpt.name = "test-element-1002"; }, 0); }); - assertSame(true, called, "Proxy 包装后的 setTimeout 应正常执行"); + assert(true, called, "Proxy 包装后的 setTimeout 应正常执行"); }); 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"); + assert(window, self, "self getter 应返回沙盒 window"); + assert(window, parent, "parent getter 应返回沙盒 window"); + assert(window, top, "top getter 应返回沙盒 window"); + assert(window, frames, "frames getter 应返回沙盒 window"); }); await test("onxxx 函数赋值由页面事件触发,event.target 为 unsafeWindow", () => @@ -487,18 +433,18 @@ mpt.name = "test-element-1002"; count++; thisIsNotWindow = this !== unsafeWindow; eventTargetIsUnsafeWindow = event.target === unsafeWindow; - assertSame("resize", event.type, "事件对象应正常传入"); + assert("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"); + assert(1, count, "页面 resize 应触发沙盒 onresize"); + assert(true, thisIsNotWindow, "onresize 回调 this 不应为 unsafeWindow"); + assert(true, eventTargetIsUnsafeWindow, "onresize 回调 event.target 应为 unsafeWindow"); window.onresize = null; unsafeWindow.dispatchEvent(new Event("resize")); unsafeWindow.dispatchEvent(new Event(eventName)); - assertSame(1, count, "清空 onresize 后不应继续触发"); + assert(1, count, "清空 onresize 后不应继续触发"); }, () => { window.onresize = null; @@ -515,10 +461,10 @@ mpt.name = "test-element-1002"; }, }; window.onfocus = listenerObject; - assertSame(listenerObject, window.onfocus, "非 primitive 对象应被保存"); + assert(listenerObject, window.onfocus, "非 primitive 对象应被保存"); unsafeWindow.dispatchEvent(new Event("focus")); await waitForEventLoop(); - assertSame( + assert( false, handled, "EventListenerObject 形式不应被 onxxx 代理注册", @@ -526,10 +472,10 @@ mpt.name = "test-element-1002"; handled = false; const func = function () { handled = true }; window.onfocus = func; - assertSame(func, window.onfocus, "function 对象应被保存"); + assert(func, window.onfocus, "function 对象应被保存"); unsafeWindow.dispatchEvent(new Event("focus")); await waitForEventLoop(); - assertSame( + assert( true, handled, "EventListener 形式应被 onxxx 代理注册", @@ -539,7 +485,7 @@ mpt.name = "test-element-1002"; assertNotSame(func, window.onfocus, "primitive 对象时注册能被移除 (1)"); unsafeWindow.dispatchEvent(new Event("focus")); await waitForEventLoop(); - assertSame( + assert( false, handled, "primitive 对象时注册能被移除 (2)", @@ -564,8 +510,8 @@ mpt.name = "test-element-1002"; }; unsafeWindow.dispatchEvent(new Event("hashchange")); - assertSame(0, oldCount, "旧 onhashchange 不应再被调用"); - assertSame(1, newCount, "新 onhashchange 应被调用一次"); + assert(0, oldCount, "旧 onhashchange 不应再被调用"); + assert(1, newCount, "新 onhashchange 应被调用一次"); }, () => { window.onhashchange = null; @@ -595,43 +541,43 @@ mpt.name = "test-element-1002"; 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"); + assert(false, Object.hasOwn(trueWindow, "addEventListener"), "unsafeWindow 继承 Object.hasOwn"); + assert(true, Reflect.has(trueWindow, "addEventListener"), "unsafeWindow 继承 Reflect.has"); + assert(true, Object.hasOwn(sandboxWindow, "addEventListener"), "window 属性 Object.hasOwn"); + assert(true, Reflect.has(sandboxWindow, "addEventListener"), "window 属性 Reflect.has"); }); 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( + assert("object", typeof GM_info, "GM_info 应可用"); + assert("object", typeof GM.info, "GM.info 应可用"); + assert(JSON.stringify(GM_info), JSON.stringify(GM.info), "GM.info 应与 GM_info 一致 (JSON.stringify)"); + assert( unsafeWindow, window.unsafeWindow, "unsafeWindow 应指向页面 window", ); - assertSame("object", typeof GM_info.script, "GM_info.script 应存在"); + assert("object", typeof GM_info.script, "GM_info.script 应存在"); }); await test("GM_ 与 GM.* 双命名空间由 grant 自动补齐", () => { - assertSame("function", typeof GM_getValue, "GM_getValue 应可用"); - assertSame( + assert("function", typeof GM_getValue, "GM_getValue 应可用"); + assert( "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( + assert("function", typeof GM_setValue, "GM_setValue 应可用"); + assert("function", typeof GM.setValue, "GM.setValue 应可用"); + assert("function", typeof GM_deleteValue, "GM_deleteValue 应可用"); + assert( "function", typeof GM.deleteValue, "GM.deleteValue 应由 GM_deleteValue grant 补齐", ); - assertSame("function", typeof GM_listValues, "GM_listValues 应可用"); - assertSame( + assert("function", typeof GM_listValues, "GM_listValues 应可用"); + assert( "function", typeof GM.listValues, "GM.listValues 应由 GM_listValues grant 补齐", @@ -644,14 +590,14 @@ mpt.name = "test-element-1002"; 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, "对象值应保持属性"); + assert("sandbox", stored.env, "GM_getValue 应读取同步写入对象"); + assert(true, stored.ok, "对象值应保持属性"); assertTrue( GM_listValues().includes(key), "GM_listValues 应包含写入的键", ); GM_deleteValue(key); - assertSame( + assert( "fallback", GM_getValue(key, "fallback"), "GM_deleteValue 应删除值", @@ -667,7 +613,7 @@ mpt.name = "test-element-1002"; async () => { const key = `${markerPrefix}_async_value`; await withTimeout(GM.setValue(key, "async-value"), "GM.setValue"); - assertSame( + assert( "async-value", await withTimeout(GM.getValue(key), "GM.getValue"), "GM.getValue 应读取 GM.setValue 写入值", @@ -677,7 +623,7 @@ mpt.name = "test-element-1002"; "GM.listValues 应包含异步写入的键", ); await withTimeout(GM.deleteValue(key), "GM.deleteValue"); - assertSame( + assert( "fallback", await withTimeout( GM.getValue(key, "fallback"), @@ -700,9 +646,9 @@ mpt.name = "test-element-1002"; 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( + assert("A", picked[keyA], "GM_getValues 数组模式应返回已存在键"); + assert(1, picked[keyB].deep, "GM_getValues 应返回对象值"); + assert( false, Object.prototype.hasOwnProperty.call(picked, keyMissing), "数组模式不应包含缺失键", @@ -712,8 +658,8 @@ mpt.name = "test-element-1002"; [keyA]: "default-a", [keyMissing]: "default-missing", }); - assertSame("A", defaults[keyA], "对象模式应优先返回已存在值"); - assertSame( + assert("A", defaults[keyA], "对象模式应优先返回已存在值"); + assert( "default-missing", defaults[keyMissing], "对象模式应为缺失键返回默认值", @@ -723,7 +669,7 @@ mpt.name = "test-element-1002"; GM.getValues({ [keyB]: null }), "GM.getValues", ); - assertSame( + assert( 1, asyncPicked[keyB].deep, "GM.getValues 应由 GM_getValues grant 依赖注入", @@ -736,33 +682,33 @@ mpt.name = "test-element-1002"; )); await test("GM_cookie grant 构建函数对象与多级命名空间", () => { - assertSame("function", typeof GM_cookie, "GM_cookie 应可用"); - assertSame( + assert("function", typeof GM_cookie, "GM_cookie 应可用"); + assert( "function", typeof GM_cookie.set, "GM_cookie.set 应由兼容命名空间注入", ); - assertSame( + assert( "function", typeof GM_cookie.list, "GM_cookie.list 应由兼容命名空间注入", ); - assertSame( + assert( "function", typeof GM_cookie.delete, "GM_cookie.delete 应由兼容命名空间注入", ); - assertSame( + assert( "function", typeof GM.cookie.set, "GM.cookie.set 应由 GM.cookie 依赖注入", ); - assertSame( + assert( "function", typeof GM.cookie.list, "GM.cookie.list 应由 GM.cookie 依赖注入", ); - assertSame( + assert( "function", typeof GM.cookie.delete, "GM.cookie.delete 应由 GM.cookie 依赖注入", @@ -776,8 +722,8 @@ mpt.name = "test-element-1002"; const style = GM_addStyle( `.${className} { color: rgb(1, 2, 3) !important; }`, ); - assertSame("STYLE", style.tagName, "GM_addStyle 应创建 style 标签"); - assertSame( + assert("STYLE", style.tagName, "GM_addStyle 应创建 style 标签"); + assert( document, style.ownerDocument, "GM_addStyle 返回元素应属于页面 document", @@ -789,12 +735,12 @@ mpt.name = "test-element-1002"; ), "GM.addStyle", ); - assertSame( + assert( "STYLE", asyncStyle.tagName, "GM.addStyle 应 resolve style 标签", ); - assertSame( + assert( document, asyncStyle.ownerDocument, "GM.addStyle 返回元素应属于页面 document", @@ -819,13 +765,13 @@ mpt.name = "test-element-1002"; textContent: "ScriptCat sandbox test", hidden: true, }); - assertSame("DIV", div.tagName, "GM_addElement(tag, attrs) 应创建元素"); - assertSame( + assert("DIV", div.tagName, "GM_addElement(tag, attrs) 应创建元素"); + assert( document, div.ownerDocument, "默认创建元素应属于页面 document", ); - assertSame(true, div.hidden, "boolean 应通过 property setter 设置"); + assert(true, div.hidden, "boolean 应通过 property setter 设置"); const child = await withTimeout( GM.addElement(div, "span", { @@ -833,22 +779,22 @@ mpt.name = "test-element-1002"; }), "GM.addElement", ); - assertSame( + assert( "SPAN", child.tagName, "GM.addElement(parent, tag, attrs) 应创建子元素", ); - assertSame(div, child.parentNode, "显式 parent 应生效"); + assert(div, child.parentNode, "显式 parent 应生效"); const script = GM_addElement("script", { textContent: `window["${key}"] = "from-gm-add-element";`, }); - assertSame( + assert( "from-gm-add-element", unsafeWindow[key], "GM_addElement 插入 script 应在页面 window 执行", ); - assertSame( + assert( undefined, window[key], "页面执行结果不应自动写回沙盒 window", @@ -862,12 +808,12 @@ mpt.name = "test-element-1002"; )); await test("window.close/window.focus grant 暴露为沙盒 window 方法", () => { - assertSame( + assert( "function", typeof window.close, "window.close grant 应暴露 close", ); - assertSame( + assert( "function", typeof window.focus, "window.focus grant 应暴露 focus", @@ -887,15 +833,15 @@ mpt.name = "test-element-1002"; section("兼容行为"); await test("Object 静态方法与 RegExp 静态状态保持可用", () => { - assertSame( + assert( true, Object.isFrozen(Object.freeze({})), "Object.freeze 应可裸用", ); const match = "abc123".match(/(\d+)/); - assertSame("123", match && match[1], "RegExp match 应正常返回捕获组"); - assertSame("123", RegExp.$1, "RegExp.$1 应保留页面原生静态状态行为"); + assert("123", match && match[1], "RegExp match 应正常返回捕获组"); + assert("123", RegExp.$1, "RegExp.$1 应保留页面原生静态状态行为"); }); await test("Symbol 属性只写入当前沙盒,不影响页面 window", () => @@ -903,12 +849,12 @@ mpt.name = "test-element-1002"; () => { const symbolKey = Symbol(`${markerPrefix}_symbol`); window[symbolKey] = "sandbox-symbol"; - assertSame( + assert( "sandbox-symbol", window[symbolKey], "沙盒应允许 Symbol 属性", ); - assertSame( + assert( undefined, unsafeWindow[symbolKey], "Symbol 属性不应写入页面 window", @@ -925,32 +871,95 @@ mpt.name = "test-element-1002"; 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"); + assert("from-eval", window[key], "eval 应能写入沙盒 window"); + assert(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;", - ); + printSummary(); +})((() => { + const testResults = { + passed: 0, + failed: 0, + total: 0, + }; - if (testResults.failed === 0) { + function formatValue(value) { + if (value === window) return "[sandbox window]"; + if (value === unsafeWindow) return "[unsafeWindow]"; + if (value === document) return "[document]"; + if (value && value.nodeType) return `[node ${value.nodeName}]`; + if (typeof value === "function") + return `[function ${value.name || "anonymous"}]`; + if (typeof value === "symbol") return value.toString(); + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + function assert(expected, actual, message) { + if (!Object.is(expected, actual)) { + throw new Error( + `${message} - 期望 ${formatValue(expected)}, 实际 ${formatValue(actual)}`, + ); + } + } + + function assertNotSame(unexpected, actual, message) { + if (Object.is(unexpected, actual)) { + throw new Error(`${message} - 不应等于 ${formatValue(unexpected)}`); + } + } + + function assertTrue(condition, message) { + if (!condition) throw new Error(message || "断言失败: 条件不为真"); + } + + function section(name) { + console.log(`\n%c--- ${name} ---`, "color: orange; font-weight: bold;"); + } + + 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 printSummary() { console.log( - "%c所有测试通过", - "color: green; font-size: 14px; font-weight: bold;", + "\n%c=== 测试完成 ===", + "color: blue; font-size: 16px; font-weight: bold;", ); - } else { console.log( - "%c部分测试失败,请检查上面的错误信息", - "color: red; font-size: 14px; font-weight: bold;", + `%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;", + ); + } } -})(); + + return { test, assert, assertNotSame, assertTrue, section, printSummary }; +})()); diff --git a/example/tests/unwrap_e2e_test.js b/example/tests/unwrap_e2e_test.js index 680bce1b8..66cff7a15 100644 --- a/example/tests/unwrap_e2e_test.js +++ b/example/tests/unwrap_e2e_test.js @@ -11,7 +11,28 @@ var __unwrap_e2e_global_var = "unwrap_success"; -(function () { +(async ({ test, assert, printSummary }) => { + // ============ @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, "全局变量应可访问"); + }); + + // ============ 测试总结 ============ + printSummary(); +})((() => { + // 跟测试对象无关的基础设施:计数器、test/assert 断言函数、结果汇总打印。 "use strict"; let testResults = { @@ -42,26 +63,12 @@ var __unwrap_e2e_global_var = "unwrap_success"; } } - // ============ @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, "全局变量应可访问"); - }); + function printSummary() { + 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("\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;"); -})(); + return { test, assert, printSummary }; +})()); diff --git a/example/tests/unwrap_test.js b/example/tests/unwrap_test.js index 87f82e676..4c9337a82 100644 --- a/example/tests/unwrap_test.js +++ b/example/tests/unwrap_test.js @@ -17,7 +17,7 @@ var test_global_injection = "success"; // User can access the variable "test_global_injection" directly in DevTools -(function () { +(async ({ printSummary }) => { const results = { GM: { expected: "undefined", @@ -33,34 +33,41 @@ var test_global_injection = "success"; }, }; - console.group( - "%c@unwrap Test", - "color:#0aa;font-weight:bold" - ); + printSummary(results); +})((() => { + // 跟测试对象无关的基础设施:控制台分组/表格输出与汇总打印。 + function printSummary(results) { + console.group( + "%c@unwrap Test", + "color:#0aa;font-weight:bold" + ); - const table = {}; - let allPass = true; + const table = {}; + let allPass = true; - for (const key in results) { - const { expected, actual } = results[key]; - const pass = expected === actual; - allPass &&= pass; + 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", - }; - } + table[key] = { + Expected: expected, + Actual: actual, + Result: pass ? "✅ PASS" : "❌ FAIL", + }; + } + + console.table(table); - console.table(table); + console.log( + allPass + ? "%cAll tests passed ✔" + : "%cSome tests failed ✘", + `font-weight:bold;color:${allPass ? "green" : "red"}` + ); - console.log( - allPass - ? "%cAll tests passed ✔" - : "%cSome tests failed ✘", - `font-weight:bold;color:${allPass ? "green" : "red"}` - ); + console.groupEnd(); + } - console.groupEnd(); -})(); + return { printSummary }; +})()); diff --git a/example/tests/window_message_test.js b/example/tests/window_message_test.js index 444e21179..00c0577f1 100644 --- a/example/tests/window_message_test.js +++ b/example/tests/window_message_test.js @@ -14,15 +14,7 @@ // @noframes // ==/UserScript== -(async function () { - "use strict"; - - const results = { - passed: 0, - failed: 0, - total: 0, - }; - +(async ({ section, assert, assertTrue, withTimeout, test, printSummary }) => { console.log( "%c=== WindowMessage transport test start ===", "color: blue; font-size: 16px; font-weight: bold;", @@ -31,53 +23,13 @@ "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"); - } - } - - function withTimeout(promise, label, ms = 10000) { - let timer = null; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); - }); - 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"); + assert("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"); + assert(window, self, "self should point at the sandbox window"); + assert(window, globalThis, "globalThis should point at the sandbox window"); }); section("One-shot sendMessage path"); @@ -100,7 +52,7 @@ "GM.xmlHttpRequest", ); - assertSame(200, response.status, "status should be 200"); + assert(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"); @@ -110,7 +62,7 @@ response.response.query || response.response.params || {}; - assertSame(marker, args.marker, "query marker should round-trip through the response"); + assert(marker, args.marker, "query marker should round-trip through the response"); }); await test("GM_xmlhttpRequest forwards readyState events over the connect channel", async () => { @@ -132,7 +84,7 @@ "GM_xmlhttpRequest readyState", ); - assertSame(200, response.status, "status should be 200"); + assert(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"); }); @@ -148,8 +100,8 @@ ontimeout: reject, onabort: (res) => { try { - assertSame(0, res.readyState, "aborted readyState should be UNSENT"); - assertSame(0, res.status, "aborted status should be 0"); + assert(0, res.readyState, "aborted readyState should be UNSENT"); + assert(0, res.status, "aborted status should be 0"); resolve(); } catch (error) { reject(error); @@ -163,14 +115,69 @@ ); }); - 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;", - ); -})(); + printSummary(); +})((() => { + // 跟测试对象无关的基础设施:计数器、分节/断言/超时包装函数、结果汇总打印。 + "use strict"; + + const results = { + passed: 0, + failed: 0, + total: 0, + }; + + function section(name) { + console.log(`\n%c--- ${name} ---`, "color: orange; font-weight: bold;"); + } + + function assert(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"); + } + } + + function withTimeout(promise, label, ms = 10000) { + let timer = null; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + }); + 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; + } + } + + function printSummary() { + 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;", + ); + } + + return { section, assert, assertTrue, withTimeout, test, printSummary }; +})());