diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 9e30f5ddc..751321de4 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -125,6 +125,7 @@ async function startGMApiMockServer(): Promise { res.end( JSON.stringify({ url: `http://${req.headers.host}${url.pathname}`, + method: req.method, args, }) ); @@ -221,14 +222,14 @@ function patchGMApiTestCode(code: string, mockOrigin: string): string { const mockHost = new URL(mockOrigin).host; return code .replace(/^\/\/\s*@connect\s+api\.github\.com$/gm, `// @connect ${MOCK_CONNECT_HOST}`) - .replace(/^\/\/\s*@connect\s+httpbun\.com$/gm, `// @connect ${MOCK_CONNECT_HOST}`) + .replace(/^\/\/\s*@connect\s+mockhttp\.org$/gm, `// @connect ${MOCK_CONNECT_HOST}`) .replace(/https:\/\/api\.github\.com\/repos\/scriptscat\/scriptcat/g, `${mockOrigin}/repos/scriptscat/scriptcat`) - .replace(/https:\/\/httpbun\.com\/get/g, `${mockOrigin}/get`) - .replace(/https:\/\/httpbun\.com\/bytes\/64/g, `${mockOrigin}/bytes/64`) - .replace(/https:\/\/httpbun\.com\/delay\/5/g, `${mockOrigin}/delay/5`) + .replace(/https:\/\/mockhttp\.org\/get/g, `${mockOrigin}/get`) + .replace(/https:\/\/mockhttp\.org\/bytes\/64/g, `${mockOrigin}/bytes/64`) + .replace(/https:\/\/mockhttp\.org\/delay\/5/g, `${mockOrigin}/delay/5`) .replace(/https:\/\/www\.tampermonkey\.net\/favicon\.ico/g, `${mockOrigin}/favicon.ico`) .replace(/api\.github\.com\/repos\/scriptscat\/scriptcat/g, `${mockHost}/repos/scriptscat/scriptcat`) - .replace(/httpbun\.com\/get/g, `${mockHost}/get`); + .replace(/mockhttp\.org\/get/g, `${mockHost}/get`); } async function runTestScript( diff --git a/example/crontab/crontab.js b/example/crontab/crontab.js index e590eaf4b..bf9cc169c 100644 --- a/example/crontab/crontab.js +++ b/example/crontab/crontab.js @@ -7,26 +7,26 @@ // @crontab */15 14-16 * * * // @grant GM_log // @grant GM_xmlhttpRequest -// @connect httpbun.com +// @connect mockhttp.org // ==/UserScript== return new Promise((resolve, reject) => { GM_log("下午两点至四点每十五分钟运行一次"); GM_xmlhttpRequest({ - url: "https://httpbun.com/get", + url: "https://mockhttp.org/get", method: "GET", responseType: "json", anonymous: true, onload(resp) { const data = resp.response; - if (resp.status !== 200 || data?.url !== "https://httpbun.com/get") { + if (resp.status !== 200 || resp.finalUrl !== "https://mockhttp.org/get" || data?.method !== "GET") { reject("服务器回应错误。"); return; } - GM_log(`定时请求成功:\n${data.url}`); + GM_log(`定时请求成功:\n${resp.finalUrl}`); resolve(); }, onerror() { diff --git a/example/tests/gm_api_async_test.js b/example/tests/gm_api_async_test.js index c8535935c..f1648c9d6 100644 --- a/example/tests/gm_api_async_test.js +++ b/example/tests/gm_api_async_test.js @@ -24,7 +24,7 @@ // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab -// @connect httpbun.com +// @connect mockhttp.org // @connect example.com // @run-at document-start // ==/UserScript== @@ -192,7 +192,7 @@ return new Promise((resolve, reject) => { GM.xmlHttpRequest({ method: "GET", - url: "https://httpbun.com/get", + url: "https://mockhttp.org/get", timeout: 10000, onload: (response) => { try { @@ -200,8 +200,9 @@ assert(true, !!response.responseText, "响应内容不应为空"); const data = JSON.parse(response.responseText); assert("object", typeof data, "应该返回有效的 JSON 对象"); - assert("https://httpbun.com/get", data.url, "响应应该包含 url 字段"); - console.log("httpbun 响应信息:", data.url); + assert("GET", data.method, "响应应该包含 method 字段"); + assert(true, response.finalUrl.includes("mockhttp.org/get"), "finalUrl 应指向请求的地址"); + console.log("mockhttp 响应信息:", response.finalUrl); resolve(); } catch (error) { reject(error); @@ -220,7 +221,7 @@ await testAsync("GM.xmlHttpRequest - 返回控制对象", async () => { const controller = GM.xmlHttpRequest({ method: "GET", - url: "https://httpbun.com/get", + url: "https://mockhttp.org/get", timeout: 10000, onload: () => {}, onerror: () => {}, diff --git a/example/tests/gm_api_sync_test.js b/example/tests/gm_api_sync_test.js index 7b6008ce2..285d345a7 100644 --- a/example/tests/gm_api_sync_test.js +++ b/example/tests/gm_api_sync_test.js @@ -28,7 +28,7 @@ // @grant unsafeWindow // @require https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js#sha384-vtXRMe3mGCbOeY7l30aIg8H9p3GdeSe4IFlP6G8JMa7o7lXvnz3GFKzPxzJdPfGK // @resource testCSS https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css#sha256=62f74b1cf824a89f03554c638e719594c309b4d8a627a758928c0516fa7890ab -// @connect httpbun.com +// @connect mockhttp.org // @connect example.com // @run-at document-start // ==/UserScript== @@ -308,7 +308,7 @@ return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", - url: "https://httpbun.com/get", + url: "https://mockhttp.org/get", timeout: 10000, onload: (response) => { try { @@ -316,8 +316,9 @@ assert(true, !!response.responseText, "响应内容不应为空"); const data = JSON.parse(response.responseText); assert("object", typeof data, "应该返回有效的 JSON 对象"); - assert("https://httpbun.com/get", data.url, "响应应该包含 url 字段"); - console.log("httpbun 响应信息:", data.url); + assert("GET", data.method, "响应应该包含 method 字段"); + assert(true, response.finalUrl.includes("mockhttp.org/get"), "finalUrl 应指向请求的地址"); + console.log("mockhttp 响应信息:", response.finalUrl); resolve(); } catch (error) { reject(error); diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index 8814de5ca..86cffa5ff 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -11,7 +11,7 @@ // @grant GM_setValue // @grant GM_getValue // @grant GM_info -// @connect httpbun.com +// @connect mockhttp.org // @connect raw.githubusercontent.com // @connect cdn.jsdelivr.net // @connect ipv4.download.thinkbroadband.com @@ -136,8 +136,8 @@ const enableTool = true; // File extends Blob; useful to verify the URL-object branch handles File too. const TEXT_FILE = new File([TEXT_BLOB], "ignored-by-api.txt", { type: "text/plain" }); - // httpbun deterministic endpoint — returns N random bytes. - const HB = "https://httpbun.com"; + // mockhttp deterministic endpoint — returns N random bytes. + const HB = "https://mockhttp.org"; // ---------- Panel ---------- const panel = h( @@ -697,7 +697,7 @@ const enableTool = true; skip(`(visual check) you should see both uniquify-target.txt and uniquify-target (1).txt`); }); - // 13) headers honored in native mode — httpbun /headers echoes request headers + // 13) headers honored in native mode — mockhttp /headers echoes request headers autoTest("headers passed to backend xhr (native mode)", async () => { // We can't easily inspect what hit the server because the response is // streamed to disk. So instead: ask /headers, which RESPONDS with the headers diff --git a/example/tests/gm_xhr_redirect_test.js b/example/tests/gm_xhr_redirect_test.js index c54682c6f..9e3085a14 100644 --- a/example/tests/gm_xhr_redirect_test.js +++ b/example/tests/gm_xhr_redirect_test.js @@ -6,7 +6,7 @@ // @author you // @match *://*/*?GM_XHR_REDIRECT_TEST_SC // @grant GM_xmlhttpRequest -// @connect httpbun.com +// @connect mockhttp.org // @noframes // ==/UserScript== @@ -104,7 +104,7 @@ const enableTool = true; }); } - const HB = "https://httpbun.com"; + const HB = "https://mockhttp.org"; // ---------- Assertion utils ---------- function assertEq(a, b, msg) { @@ -131,9 +131,9 @@ const enableTool = true; 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(res.response?.queryParams?.testing, "234", "response ok"); + assertEq(res.response?.queryParams?.abc, "567", "response ok"); + assertEq(res.finalUrl, `${HB}/get?testing=234&abc=567`, "response ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -142,9 +142,9 @@ const enableTool = true; 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(res.response?.queryParams?.testing, "234", "response ok"); + assertEq(res.response?.queryParams?.abc, "567", "response ok"); + assertEq(res.finalUrl, `${HB}/get?abc=567&testing=234`, "response ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -152,9 +152,13 @@ const enableTool = true; name: "Redirect handling (finalUrl changes) [default]", async run(fetch) { const target = `${HB}/get?z=92`; - const { res } = await gmRequest({ method: "GET", url: `${HB}/redirect-to?url=${encodeURIComponent(target)}`, fetch }); + const { res } = await gmRequest({ method: "GET", url: `${HB}/redirect-to?url=${encodeURIComponent(target)}`, responseType: "json", fetch }); assertEq(res.status, 200, "status after redirect is 200"); assertEq(res.finalUrl, target, "finalUrl is redirected target"); + // finalUrl alone only proves the client thinks it followed the + // redirect - confirm the server actually served /get's response. + assertEq(res.response?.method, "GET", "redirected request reached /get as GET"); + assertEq(res.response?.queryParams?.z, "92", "redirected response echoes the target's query"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -162,9 +166,11 @@ const enableTool = true; name: "Redirect handling (finalUrl changes) [follow]", async run(fetch) { const target = `${HB}/get?z=94`; - const { res } = await gmRequest({ method: "GET", url: `${HB}/redirect-to?url=${encodeURIComponent(target)}`, redirect: "follow", fetch }); + const { res } = await gmRequest({ method: "GET", url: `${HB}/redirect-to?url=${encodeURIComponent(target)}`, redirect: "follow", responseType: "json", fetch }); assertEq(res.status, 200, "status after redirect is 200"); assertEq(res.finalUrl, target, "finalUrl is redirected target"); + assertEq(res.response?.method, "GET", "redirected request reached /get as GET"); + assertEq(res.response?.queryParams?.z, "94", "redirected response echoes the target's query"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -194,7 +200,16 @@ const enableTool = true; gmRequest({ method: "GET", url, redirect: "manual", fetch }), new Promise(resolve => setTimeout(resolve, 4000)), ]); - assertEq(res?.status, 301, "status is 301"); + // NOT actually testing the server's real redirect status here: with + // redirect: "manual", implementations of this option commonly route + // through fetch()'s opaqueredirect response, whose real status the + // Fetch spec makes unreadable from JS - so a manager typically falls + // back to a fixed status (often 301) rather than the server's actual + // code. Confirmed the live server here returns 302 (verified with + // curl), yet the reported status is 301 regardless. This assertion + // pins down that fixed fallback value, not mockhttp.org's behavior - + // if your manager reports a different fallback, adjust accordingly. + assertEq(res?.status, 301, "status is 301 (manager's fixed fallback for opaque manual redirects, not the server's real code)"); 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"); diff --git a/example/tests/gm_xhr_test.js b/example/tests/gm_xhr_test.js index 2cccdf56a..a846aac99 100644 --- a/example/tests/gm_xhr_test.js +++ b/example/tests/gm_xhr_test.js @@ -6,7 +6,7 @@ // @author you // @match *://*/*?GM_XHR_TEST_SC // @grant GM_xmlhttpRequest -// @connect httpbun.com +// @connect mockhttp.org // @connect nonexistent-domain-abcxyz.test // @connect raw.githubusercontent.com // @connect translate.googleapis.com @@ -18,11 +18,12 @@ -------------- - Builds an in-page test runner panel. - Runs a battery of tests probing GM_xmlhttpRequest options, callbacks, and edge/abnormal paths. - - Uses httpbin.org endpoints for deterministic echo/response behavior. + - Uses mockhttp.org endpoints for deterministic echo/response behavior. - Prints a summary and a detailed per-test log with assertions. - NOTE: Endpoints now point to https://httpbun.com (a faster httpbin-like service). - See https://httpbun.com for docs and exact paths. (Also supports /get, /post, /bytes/{n}, /delay/{s}, /status/{code}, /redirect-to, /headers, /any, etc.) + NOTE: Endpoints point to https://mockhttp.org (open source, https://github.com/jaredwray/mockhttp). + See https://mockhttp.org for docs and exact paths. (Supports /get, /post, /put, /patch, /delete, + /anything, /bytes/{n}, /delay/{s}, /status/{code}, /redirect-to, /headers, /ip, /basic-auth, etc.) */ /* @@ -31,7 +32,7 @@ ✓ method (GET/POST/PUT/DELETE/HEAD/OPTIONS) ✓ url & redirects (finalUrl) ✓ headers (custom headers echoed by server) - ✓ data (form-encoded, JSON, and raw/binary body) + ✓ data (JSON body; mockhttp.org only accepts application/json bodies) ✓ responseType: '', 'json', 'arraybuffer', 'blob' ✓ overrideMimeType ✓ timeout + ontimeout @@ -439,20 +440,21 @@ const enableTool = true; }); } - // Switched base host from httpbin to httpbun (faster). - // See: https://httpbun.com (endpoints: /get, /post, /bytes/{n}, /delay/{s}, /status/{code}, /redirect-to, /headers, /any, etc.) - const HB = "https://httpbun.com"; + // Switched base host from httpbun to mockhttp.org (open source, more stable). + // See: https://mockhttp.org (endpoints: /get, /post, /bytes/{n}, /delay/{s}, /status/{code}, /redirect-to, /headers, /anything, etc.) + const HB = "https://mockhttp.org"; - // Helper: handle minor schema diffs between httpbin/httpbun for query echo + // Helper: handle schema diffs across httpbin/httpbun/mockhttp for query echo + // (mockhttp's /get uses "queryParams"; /anything uses "args" like httpbin). function getQueryObj(body) { - // httpbin uses "args", httpbun may use "query" (and still often provides "args" for compatibility). - return body.args || body.query || body.params || {}; + return body.args || body.query || body.queryParams || body.params || {}; } - const encodedBase64 = - "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4gVGhpcyBzZW50ZW5jZSBjb250YWlucyBldmVyeSBsZXR0ZXIgb2YgdGhlIEVuZ2xpc2ggYWxwaGFiZXQgYW5kIGlzIG9mdGVuIHVzZWQgZm9yIHR5cGluZyBwcmFjdGljZSwgZm9udCB0ZXN0aW5nLCBhbmQgZW5jb2RpbmcgZXhwZXJpbWVudHMuIEJhc2U2NCBlbmNvZGluZyB0cmFuc2Zvcm1zIHRoaXMgcmVhZGFibGUgdGV4dCBpbnRvIGEgc2VxdWVuY2Ugb2YgQVNDSUkgY2hhcmFjdGVycyB0aGF0IGNhbiBzYWZlbHkgYmUgdHJhbnNtaXR0ZWQgb3Igc3RvcmVkIGluIHN5c3RlbXMgdGhhdCBoYW5kbGUgdGV4dC1vbmx5IGRhdGEu"; - const decodedBase64 = - "The quick brown fox jumps over the lazy dog. This sentence contains every letter of the English alphabet and is often used for typing practice, font testing, and encoding experiments. Base64 encoding transforms this readable text into a sequence of ASCII characters that can safely be transmitted or stored in systems that handle text-only data."; + // mockhttp.org's /base64/{value} route rejects path segments longer than + // ~100 characters with a 404 (undocumented limit, verified empirically), so + // the payload here must stay short unlike httpbun's unbounded /base64 route. + const encodedBase64 = "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZy4gUGFjayBteSBib3gh"; + const decodedBase64 = "The quick brown fox jumps over the lazy dog. Pack my box!"; // ---------- Tests ---------- const basicTests = [ @@ -468,7 +470,10 @@ const enableTool = true; 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"); + // mockhttp.org serves /base64 as text/html (unlike httpbun's plain + // text), so GM_xhr correctly parses responseXML as an HTMLDocument + // rather than an XMLDocument - check the common base class instead. + assertEq(res.responseXML instanceof Document, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -485,7 +490,7 @@ const enableTool = true; 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(res.responseXML instanceof Document, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -502,7 +507,7 @@ const enableTool = true; 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(res.responseXML instanceof Document, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -520,7 +525,7 @@ const enableTool = true; 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(res.responseXML instanceof Document, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -536,8 +541,8 @@ const enableTool = true; }); 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(res.response instanceof Document, true, "response ok"); + assertEq(res.responseXML instanceof Document, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -571,7 +576,7 @@ const enableTool = true; 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(res.responseXML instanceof Document, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -588,7 +593,7 @@ const enableTool = true; 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(res.responseXML instanceof Document, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -602,8 +607,8 @@ const enableTool = true; 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.responseText}`.includes("Response with status code 200"), true, "responseText ok"); + assertEq(`${res.response}`.includes("Response with status code 200"), true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, @@ -619,8 +624,8 @@ const enableTool = true; 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.responseText}`.includes("Response with status code 200"), true, "responseText ok"); + assertEq(`${res.response}`.includes("Response with status code 200"), true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, @@ -636,8 +641,8 @@ const enableTool = true; 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.responseText}`.includes("Response with status code 200"), true, "responseText ok"); + assertEq(`${res.response}`.includes("Response with status code 200"), true, "response ok"); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, @@ -653,8 +658,12 @@ const enableTool = true; 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.responseText}`.includes("Response with status code 200"), true, "responseText ok"); + assertEq( + typeof res.response === "object" && `${res.response?.status}`.includes("200"), + true, + "response ok" + ); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, @@ -670,7 +679,7 @@ const enableTool = true; fetch, }); assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assertEq(`${res.responseText}`.includes("Response with status 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"); @@ -704,7 +713,7 @@ const enableTool = true; fetch, }); assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assertEq(`${res.responseText}`.includes("Response with status 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"); @@ -721,7 +730,7 @@ const enableTool = true; fetch, }); assertEq(res.status, 200, "status 200"); - assertEq(`${res.responseText}`.includes('"code": 200'), true, "responseText ok"); + assertEq(`${res.responseText}`.includes("Response with status 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"); @@ -894,6 +903,13 @@ const enableTool = true; }); assertEq(res.status, 200, "status after redirect is 200"); assertEq(res.finalUrl, target, "finalUrl is redirected target"); + // finalUrl alone only proves the client thinks it followed the + // redirect - confirm the server actually served /get's response, + // not e.g. a cached/stale/error body that happens to carry a 200. + const body = JSON.parse(res.responseText); + assertEq(body.method, "GET", "redirected request reached /get as GET"); + const q = getQueryObj(body); + assertEq(q.z, "92", "redirected response echoes the target's query"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -910,6 +926,10 @@ const enableTool = true; }); assertEq(res.status, 200, "status after redirect is 200"); assertEq(res.finalUrl, target, "finalUrl is redirected target"); + const body = JSON.parse(res.responseText); + assertEq(body.method, "GET", "redirected request reached /get as GET"); + const q = getQueryObj(body); + assertEq(q.z, "94", "redirected response echoes the target's query"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -955,30 +975,24 @@ const enableTool = true; }), new Promise((resolve) => setTimeout(resolve, 4000)), ]); - assertEq(res?.status, 301, "status is 301"); + // NOT actually testing the server's real redirect status here: with + // redirect: "manual", implementations of this option commonly route + // through fetch()'s opaqueredirect response, whose real status the + // Fetch spec makes unreadable from JS - so a manager typically falls + // back to a fixed status (often 301) rather than the server's actual + // code. Confirmed the live server here returns 302 (verified with + // curl), yet the reported status is 301 regardless. This assertion + // pins down that fixed fallback value, not mockhttp.org's behavior - + // if your manager reports a different fallback, adjust accordingly. + assertEq(res?.status, 301, "status is 301 (manager's fixed fallback for opaque manual redirects, not the server's real code)"); 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"); }, }, - { - name: "POST form-encoded data", - async run(fetch) { - const params = new URLSearchParams({ a: "1", b: "two" }).toString(); - const { res } = await gmRequest({ - method: "POST", - url: `${HB}/post`, - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - data: params, - 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"); - }, - }, + // Note: mockhttp.org's /post rejects application/x-www-form-urlencoded bodies + // with 415 Unsupported Media Type, so the form-encoded POST case from the + // httpbun/httpbin era can no longer be exercised against this server. { name: "POST JSON body", async run(fetch) { @@ -992,27 +1006,13 @@ const enableTool = true; }); const body = JSON.parse(res.responseText); assertEq(res.status, 200); - assertDeepEq(body.json, payload, "JSON echo matches"); - assertEq(objectProps(res), "ok", "Object Props OK"); - }, - }, - { - name: "Send binary body (Uint8Array) + responseType text", - async run(fetch) { - const bytes = new Uint8Array([1, 2, 3, 4, 5]); - const { res } = await gmRequest({ - method: "POST", - url: `${HB}/post`, - binary: true, - data: bytes, - fetch, - }); - const body = JSON.parse(res.responseText); - assertEq(res.status, 200); - assert(body.data && body.data.length > 0, "server received some data"); + assertDeepEq(body.body, payload, "JSON echo matches"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, + // Note: mockhttp.org's /post (and /anything) reject any request body whose + // Content-Type is not application/json with 415 Unsupported Media Type, so + // raw/binary body upload can no longer be exercised against this server. { name: "responseType=arraybuffer (download bytes)", async run(fetch) { @@ -1055,7 +1055,7 @@ const enableTool = true; assertEq(buf.byteLength, size, "byte length matches"); assert(progressCounter >= 1, "progressCounter >= 1"); assertEq(objectProps(res), "ok", "Object Props OK"); - // Do not assert image MIME; httpbun returns octet-stream here. + // Do not assert image MIME; mockhttp returns octet-stream here. }, }, { @@ -1070,7 +1070,7 @@ const enableTool = true; }); assertEq(res.status, 200); assert(res.response && typeof res.response === "object", "parsed JSON object"); - assert(res.response.origin, "has JSON fields"); + assert(res.response.ip, "has JSON fields"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -1090,8 +1090,15 @@ const enableTool = true; }, }, { - name: "responseType=document(parser error)", + name: "responseType=document(binary content doesn't throw)", async run(fetch) { + // mockhttp.org serves /base64 as text/html (unlike whatever content + // type httpbun used), so GM_xhr parses this in HTML mode, not XML + // mode. HTML parsing never fails/produces a element + // the way XML parsing of malformed content does - it's maximally + // lenient by design - so this can no longer test the XML + // parser-error path. What's still worth verifying: binary garbage + // parses gracefully as HTML instead of throwing. const { res } = await gmRequest({ method: "GET", url: `${HB}/base64/AAAAAAEAAQA=`, @@ -1099,9 +1106,8 @@ const enableTool = true; 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.response instanceof Document, "document present"); + assert(res.responseXML !== null, "responseXML present"); }, }, { @@ -1216,7 +1222,13 @@ const enableTool = true; let progressEvents = 0; let lastLoaded = 0; let response = null; - // Use drip endpoint to stream bytes + // Use drip endpoint to stream bytes. Note: mockhttp.org is fronted by + // Cloudflare, which buffers the entire response server-side before + // releasing it - verified directly (a raw socket read showed all + // bytes arriving in one burst at the very end, regardless of the + // requested drip duration/delay). So unlike a truly incremental + // download, this can't be expected to produce multiple progress + // events; only that progress fires at least once, at completion. const { res } = await new Promise((resolve, reject) => { const start = performance.now(); GM_xmlhttpRequest({ @@ -1236,7 +1248,7 @@ const enableTool = true; }); }); assertEq(res.status, 200); - assert(progressEvents >= 4, "received at least 4 progress events"); + assert(progressEvents >= 1, "received at least 1 progress event"); // `progress` is guaranteed to fire only in the Fetch API. assert(fetch ? lastLoaded > 0 : lastLoaded >= 0, "progress loaded captured"); assert(!response, "no response"); @@ -1249,7 +1261,12 @@ const enableTool = true; let progressEvents = 0; let lastLoaded = 0; let response = null; - // Use drip endpoint to stream bytes + // Use drip endpoint to stream bytes. Note: mockhttp.org is fronted by + // Cloudflare, which buffers the entire response server-side before + // releasing it - verified directly (a raw socket read showed all + // bytes arriving in one burst at the very end, regardless of the + // requested drip duration/delay). So the reader below is expected to + // see the whole payload in essentially one read(), not several. const { res } = await new Promise((resolve, reject) => { const start = performance.now(); GM_xmlhttpRequest({ @@ -1280,7 +1297,7 @@ const enableTool = true; }); }); assertEq(res.status, 200); - assert(progressEvents >= 4, "received at least 4 progress events"); + assert(progressEvents >= 1, "received at least 1 progress event"); // `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"); @@ -1320,11 +1337,12 @@ const enableTool = true; async run(fetch) { const { res } = await gmRequest({ method: "OPTIONS", - url: `${HB}/any`, + url: `${HB}/anything`, fetch, }); - // httpbun commonly returns 200 for OPTIONS - assert(res.status === 200 || res.status === 204, "200/204 on OPTIONS"); + // mockhttp.org has no OPTIONS route registered on any endpoint (returns 404), + // unlike httpbun which commonly returned 200 for OPTIONS. + assertEq(res.status, 404, "404 on OPTIONS (no route registered)"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -1342,13 +1360,18 @@ const enableTool = true; assertEq(objectProps(res), "ok", "Object Props OK"); }, }, + // Note: mockhttp.org has a single REST-style /cookies resource (POST to set, + // GET to read, DELETE?name= to clear one), unlike httpbin/httpbun's + // /cookies/set/{name}/{value} and /cookies/delete path-style routes. { name: 'anonymous TEST - set cookie "abc"', async run(fetch) { - // httpbin echoes Cookie header in headers + // mockhttp echoes Set-Cookie for a POST /cookies {name, value} body const { res } = await gmRequest({ - method: "GET", - url: `${HB}/cookies/set/abc/123`, + method: "POST", + url: `${HB}/cookies`, + headers: { "Content-Type": "application/json" }, + data: JSON.stringify({ name: "abc", value: "123" }), fetch, }); }, @@ -1356,7 +1379,7 @@ const enableTool = true; { name: "anonymous TEST - get cookie", async run(fetch) { - // httpbin echoes Cookie header in headers + // mockhttp echoes the request's Cookie header via GET /cookies const { res } = await gmRequest({ method: "GET", url: `${HB}/cookies`, @@ -1372,7 +1395,7 @@ const enableTool = true; { name: "anonymous: true (no cookies sent)", async run(fetch) { - // httpbin echoes Cookie header in headers + // mockhttp echoes Cookie header in headers const { res } = await gmRequest({ method: "GET", url: `${HB}/headers`, @@ -1388,7 +1411,7 @@ const enableTool = true; { name: "anonymous: false (cookies sent)", async run(fetch) { - // httpbin echoes Cookie header in headers + // mockhttp echoes Cookie header in headers const { res } = await gmRequest({ method: "GET", url: `${HB}/headers`, @@ -1403,10 +1426,10 @@ const enableTool = true; { name: "anonymous TEST - delete cookies", async run(fetch) { - // httpbin echoes Cookie header in headers + // mockhttp deletes one named cookie per call via DELETE /cookies?name= const { res } = await gmRequest({ - method: "GET", - url: `${HB}/cookies/delete`, + method: "DELETE", + url: `${HB}/cookies?name=abc`, anonymous: true, fetch, }); @@ -1415,10 +1438,12 @@ const enableTool = true; { name: 'anonymous: true[2] - set cookie "def"', async run(fetch) { - // httpbin echoes Cookie header in headers + // mockhttp echoes Set-Cookie for a POST /cookies {name, value} body const { res } = await gmRequest({ - method: "GET", - url: `${HB}/cookies/set/def/456`, + method: "POST", + url: `${HB}/cookies`, + headers: { "Content-Type": "application/json" }, + data: JSON.stringify({ name: "def", value: "456" }), anonymous: true, fetch, }); @@ -1427,7 +1452,7 @@ const enableTool = true; { name: "anonymous: true[2] (no cookies sent)", async run(fetch) { - // httpbin echoes Cookie header in headers + // mockhttp echoes Cookie header in headers const { res } = await gmRequest({ method: "GET", url: `${HB}/headers`, @@ -1443,10 +1468,10 @@ const enableTool = true; { name: "anonymous TEST - delete cookies", async run(fetch) { - // httpbin echoes Cookie header in headers + // mockhttp deletes one named cookie per call via DELETE /cookies?name= const { res } = await gmRequest({ - method: "GET", - url: `${HB}/cookies/delete`, + method: "DELETE", + url: `${HB}/cookies?name=def`, anonymous: true, fetch, }); @@ -1485,15 +1510,17 @@ const enableTool = true; }, }, { - name: "Invalid method -> expected server 405 or 200 echo", + name: "Invalid method -> expected server error/echo", async run(fetch) { - // httpbun accepts any method on /headers (per docs), so status may be 200 + // mockhttp.org's edge (Cloudflare) doesn't recognize non-standard HTTP + // methods and returns a 502, rather than routing them to the app like + // httpbun did (which echoed 200/405 depending on the route). const { res } = await gmRequest({ method: "FOOBAR", url: `${HB}/headers`, fetch, }); - assert([200, 405].includes(res.status), "200 or 405 depending on server handling"); + assert([200, 404, 405, 502].includes(res.status), "200/404/405/502 depending on server handling"); assertEq(objectProps(res), "ok", "Object Props OK"); }, }, @@ -1612,8 +1639,12 @@ const enableTool = true; 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.responseText}`.includes("Response with status code 200"), true, "responseText ok"); + assertEq( + typeof res.response === "object" && `${res.response?.status}`.includes("200"), + true, + "response ok" + ); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); assertEq(objectProps(res), "ok", "Object Props OK"); }, @@ -1633,8 +1664,12 @@ const enableTool = true; }, }); 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.responseText}`.includes("Response with status code 200"), true, "responseText ok"); + assertEq( + typeof res.response === "object" && `${res.response?.status}`.includes("200"), + true, + "response ok" + ); assertEq(res.responseXML instanceof XMLDocument, true, "responseXML ok"); assertDeepEq(readyStateList, fetch ? [2, 4] : [1, 2, 3, 4], "status 200"); assertEq(objectProps(res), "ok", "Object Props OK"); diff --git a/example/tests/window_message_test.js b/example/tests/window_message_test.js index 444e21179..0a349a0db 100644 --- a/example/tests/window_message_test.js +++ b/example/tests/window_message_test.js @@ -9,7 +9,7 @@ // @grant GM_xmlhttpRequest // @grant GM.setClipboard // @grant unsafeWindow -// @connect httpbun.com +// @connect mockhttp.org // @run-at document-end // @noframes // ==/UserScript== @@ -94,20 +94,21 @@ const response = await withTimeout( GM.xmlHttpRequest({ method: "GET", - url: `https://httpbun.com/get?marker=${encodeURIComponent(marker)}`, + url: `https://mockhttp.org/get?marker=${encodeURIComponent(marker)}`, responseType: "json", }), "GM.xmlHttpRequest", ); assertSame(200, response.status, "status should be 200"); - assertTrue(response.finalUrl.includes("httpbun.com/get"), "finalUrl should be populated"); + assertTrue(response.finalUrl.includes("mockhttp.org/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"); const args = response.response.args || response.response.query || + response.response.queryParams || response.response.params || {}; assertSame(marker, args.marker, "query marker should round-trip through the response"); @@ -119,7 +120,7 @@ new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", - url: "https://httpbun.com/bytes/64", + url: "https://mockhttp.org/bytes/64", onreadystatechange: (res) => { states.push(res.readyState); }, @@ -142,7 +143,7 @@ new Promise((resolve, reject) => { const request = GM_xmlhttpRequest({ method: "GET", - url: "https://httpbun.com/delay/5", + url: "https://mockhttp.org/delay/5", onload: () => reject(new Error("request loaded before abort")), onerror: reject, ontimeout: reject, diff --git a/src/app/service/service_worker/gm_api/mv3_utils.ts b/src/app/service/service_worker/gm_api/mv3_utils.ts index a545f4e85..16b7f63eb 100644 --- a/src/app/service/service_worker/gm_api/mv3_utils.ts +++ b/src/app/service/service_worker/gm_api/mv3_utils.ts @@ -14,8 +14,8 @@ type TbgMarkerMapEntry = { const bgMarkerMap = new Map(); export const normalizeBackgroundRequestUrl = (url: string) => { const u = new URL(url); - // input "https://user:passwd@httpbun.com/basic-auth/user/passwd?q=1&r=2" - // output "https://httpbun.com/basic-auth/user/passwd?q=1&r=2" + // input "https://user:passwd@mockhttp.org/basic-auth/user/passwd?q=1&r=2" + // output "https://mockhttp.org/basic-auth/user/passwd?q=1&r=2" return `${u.origin}${u.pathname}${u.search}`; }; diff --git a/tests/example/crontab.test.ts b/tests/example/crontab.test.ts index 2853841f0..3e6ea0e72 100644 --- a/tests/example/crontab.test.ts +++ b/tests/example/crontab.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; type RequestDetails = { url: string; - onload: (response: { status: number; response: unknown }) => void; + onload: (response: { status: number; finalUrl: string; response: unknown }) => void; }; const source = readFileSync(resolve(process.cwd(), "example/crontab/crontab.js"), "utf8"); @@ -14,23 +14,24 @@ const runExample = new Function("GM_log", "GM_xmlhttpRequest", source) as ( ) => Promise; describe("定时脚本示例", () => { - it("定时请求 httpbun 并记录响应 URL", async () => { + it("定时请求 mockhttp 并记录响应 URL", async () => { const log = vi.fn(); let requestDetails: RequestDetails | undefined; const result = runExample(log, (details) => { requestDetails = details; }); - expect(requestDetails?.url).toBe("https://httpbun.com/get"); + expect(requestDetails?.url).toBe("https://mockhttp.org/get"); requestDetails?.onload({ status: 200, + finalUrl: "https://mockhttp.org/get", response: { - url: "https://httpbun.com/get", + method: "GET", }, }); await expect(result).resolves.toBeUndefined(); - expect(log).toHaveBeenCalledWith("定时请求成功:\nhttps://httpbun.com/get"); + expect(log).toHaveBeenCalledWith("定时请求成功:\nhttps://mockhttp.org/get"); }); });