Skip to content

Conversation

@CodFrm
Copy link
Member

@CodFrm CodFrm commented Dec 15, 2025

概述 Descriptions

close #1078 #1080

我增加了 readyState === 4 判断,去修复 #1078 的问题,你看看是不是正确

正在处理 #1080

变更内容 Changes

截图 Screenshots

@CodFrm CodFrm requested a review from cyfung1031 December 15, 2025 07:10
@cyfung1031
Copy link
Collaborator

            get response() {
              if (response === false) {

onload 时,资料有跑到 content / page, 但不知为何 response 变成了 null, 所以跳过了

先不要合并,我再看看

@CodFrm
Copy link
Member Author

CodFrm commented Dec 15, 2025

#1080 的 onreadystatechange 是fetch模式下的错误行为(原版的xhr也没有这个弄错了),我处理一下

@cyfung1031 cyfung1031 self-assigned this Dec 15, 2025
@CodFrm CodFrm added P0 🚑 需要紧急处理的内容 hotfix 需要尽快更新到扩展商店 labels Dec 15, 2025
@cyfung1031
Copy link
Collaborator

// ==UserScript==
// @name         New Userscript M33
// @namespace    http://tampermonkey.net/
// @version      2025-12-15
// @description  try to take over the world!
// @author       You
// @match        https://example.com/?m33
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function () {
  'use strict';


  GM_xmlhttpRequest({
    method: "GET",
    url: "https://example.com/",
    onreadystatechange: function (response) {
      console.log(response);
    },
    onload: function (response) {
      console.log(response.responseText);
    }
  });


  // Your code here...
})();

我用这个在TM跑,清楚看到 1,2,3,4

Screenshot 2025-12-15 at 20 09 48 Screenshot 2025-12-15 at 20 09 52

因为 TM 不像fetch_xhr的完全模拟XHR,所以只有 2和4
为了跟TM一致,我在上层把 1 3 过滤掉吧

Screenshot 2025-12-15 at 20 11 17

@cyfung1031
Copy link
Collaborator

cyfung1031 commented Dec 15, 2025

修好了

// ==UserScript==
// @name         New Userscript M33
// @namespace    http://tampermonkey.net/
// @version      2025-12-15
// @description  try to take over the world!
// @author       You
// @match        https://example.com/?m33sc
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function () {
  'use strict';


  GM_xmlhttpRequest({
    method: "GET",
    anonymous: true, // 注釋掉 -> xhr; 保留 -> fetchXhr
    url: "https://example.com/",
    onreadystatechange: function (response) {
      console.log(response)
    },
    onload: function (response) {
      console.log(response.responseText);
    }
  });


  // Your code here...
})();
// ==UserScript==
// @name         Test #1078
// @namespace    https://docs.scriptcat.org/
// @version      0.1.0
// @description  try to take over the world!
// @author       You
// @match        https://example.com/?test1078
// @icon         https://www.google.com/s2/favicons?sz=64&domain=docs.scriptcat.org
// @grant        GM_xmlhttpRequest
// ==/UserScript==

console.log("Test #1078")
GM_xmlhttpRequest({
    url: "https://ext.scriptcat.org/api/v1/system/version",
    responseType: "json",
    onload(resp) {
        console.log("onload", resp.response)
    },
    onloadstart(resp) {
        console.log("onloadstart", resp.response)
    },
    onprogress(resp) {
        console.log("onprogress", resp);
    }
});

@cyfung1031
Copy link
Collaborator

加入 allowResponse 特殊处理:readyState 达至 4 之前,排除 response, responseText, responseXML

我在修正问题后,发现 "GM_xmlhttpRequest Exhaustive Test Harness v2" 有一些过不了
于是发现,TM在readyState 4 之前都不会有 response, responseText, responseXML
(error 另计)
因此加入 allowResponse 的控制,使行为更贴近TM

// ==UserScript==
// @name         GM_xmlhttpRequest Exhaustive Test Harness v2
// @namespace    tm-gmxhr-test
// @version      1.2.0
// @description  Comprehensive in-page tests for GM_xmlhttpRequest: normal, abnormal, and edge cases with clear pass/fail output.
// @author       you
// @match        *://*/*?GM_XHR_TEST_SC
// @grant        GM_xmlhttpRequest
// @connect      httpbun.com
// @connect      ipv4.download.thinkbroadband.com
// @connect      nonexistent-domain-abcxyz.test
// @noframes
// ==/UserScript==

/*
  WHAT THIS DOES
  --------------
  - Builds an in-page test runner panel.
  - Runs a battery of tests probing GM_xmlhttpRequest options, callbacks, and edge/abnormal paths.
  - Uses httpbin.org endpoints for deterministic echo/response behavior.
  - Prints a summary and a detailed per-test log with assertions.

  NOTE: Endpoints now point to https://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.)
*/

/*
  WHAT IT COVERS
  --------------
  ✓ method (GET/POST/PUT/DELETE/HEAD/OPTIONS)
  ✓ url & redirects (finalUrl)
  ✓ headers (custom headers echoed by server)
  ✓ data (form-encoded, JSON, and raw/binary body)
  ✓ responseType: '', 'json', 'arraybuffer', 'blob'
  ✓ overrideMimeType
  ✓ timeout + ontimeout
  ✓ onprogress (with streaming-ish endpoint)
  ✓ onload (non-2xx still onload)
  ✓ onerror (DNS/blocked host)
  ✓ onabort (manual abort)
  ✓ anonymous (no cookies)
  ✓ basic auth (user/password)
  ✓ edge cases: huge headers trimmed? invalid method; invalid URL; missing @connect domain triggers onerror
*/

const enableTool = true;
(function () {
  'use strict';
  if (!enableTool) return;

  // ---------- Small DOM helper ----------
  function h(tag, props = {}, ...children) {
    const el = document.createElement(tag);
    Object.entries(props).forEach(([k, v]) => {
      if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
      else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
      else el[k] = v;
    });
    for (const c of children) el.append(c && c.nodeType ? c : document.createTextNode(String(c)));
    return el;
  }

  // ---------- 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', { style: { fontWeight: '600' } }, 'GM_xmlhttpRequest Test Harness'),
      h('div', { id: 'counts', style: { marginLeft: 'auto', opacity: .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: .9 } }, 'Status: idle'),
    h('details', { id: 'queueWrap', open: true, 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: .8 } }, '(none)')
    ),
    h('div', { id: 'log', style: { padding: '10px 12px' } })
  );
  document.documentElement.append(panel);

  function btn() {
    return {
      background: '#2a6df1', color: 'white', border: '0', padding: '6px 10px',
      borderRadius: '6px', cursor: 'pointer'
    };
  }

  const $log = panel.querySelector('#log');
  const $counts = panel.querySelector('#counts');
  const $status = panel.querySelector('#status');
  const $queue = panel.querySelector('#queue');
  panel.querySelector('#clear').addEventListener('click', () => { $log.textContent = ''; setCounts(0, 0, 0); setStatus('idle'); setQueue([]); });
  panel.querySelector('#start').addEventListener('click', runAll);

  function logLine(html, cls = '') {
    const line = h('div', { style: { padding: '6px 0', borderBottom: '1px dashed #2a2a2a' } });
    line.innerHTML = html;
    if (cls) line.className = cls;
    $log.prepend(line);
  }

  function setCounts(p, f, s) {
    $counts.textContent = `✅ ${p}${f}${s}`;
  }
  function setStatus(text) {
    $status.textContent = `Status: ${text}`;
  }
  function setQueue(items) {
    $queue.textContent = items.length ? items.map((t, i) => `${i + 1}. ${t}`).join('\n') : '(none)';
  }

  // ---------- 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 ? `<pre style="white-space:pre-wrap;color:#bbb;margin:.5em 0 0">${escapeHtml(extra)}</pre>` : ''}`, '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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m])); }

  function gmRequest(details, { abortAfterMs } = {}) {
    return new Promise((resolve, reject) => {
      const t0 = performance.now();
      const req = GM_xmlhttpRequest({
        ...details,
        onload: (res) => resolve({ kind: 'load', res, ms: performance.now() - t0 }),
        onerror: (res) => reject({ kind: 'error', res, ms: performance.now() - t0 }),
        ontimeout: (res) => reject({ kind: 'timeout', res, ms: performance.now() - t0 }),
        onabort: (res) => reject({ kind: 'abort', res, ms: performance.now() - t0 }),
        onprogress: details.onprogress
      });
      if (abortAfterMs != null) {
        setTimeout(() => { try { req.abort(); } catch (_) { /* ignore */ } }, abortAfterMs);
      }
    });
  }

  // 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';

  // Helper: handle minor schema diffs between httpbin/httpbun for query echo
  function getQueryObj(body) {
    // httpbin uses "args", httpbun may use "query" (and still often provides "args" for compatibility).
    return body.args || body.query || 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.";

  // ---------- Tests ----------
  const basicTests = [
    {
      name: 'GET basic [responseType: undefined]',
      async run(fetch) {
        const url = `${HB}/base64/${encodedBase64}`;
        const { res } = await gmRequest({
          method: 'GET',
          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');
      }
    },
    {
      name: 'GET basic [responseType: ""]',
      async run(fetch) {
        const url = `${HB}/base64/${encodedBase64}`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET basic [responseType: "text"]',
      async run(fetch) {
        const url = `${HB}/base64/${encodedBase64}`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },





    {
      name: 'GET basic [responseType: "json"]',
      async run(fetch) {
        const url = `${HB}/base64/${encodedBase64}`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET basic [responseType: "document"]',
      async run(fetch) {
        const url = `${HB}/base64/${encodedBase64}`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET basic [responseType: "stream"]',
      async run(fetch) {
        const url = `${HB}/base64/${encodedBase64}`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET basic [responseType: "arraybuffer"]',
      async run(fetch) {
        const url = `${HB}/base64/${encodedBase64}`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET basic [responseType: "blob"]',
      async run(fetch) {
        const url = `${HB}/base64/${encodedBase64}`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET json [responseType: undefined]',
      async run(fetch) {
        const url = `${HB}/status/200`;
        const { res } = await gmRequest({
          method: 'GET',
          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');
      }
    },
    {
      name: 'GET json [responseType: ""]',
      async run(fetch) {
        const url = `${HB}/status/200`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET json [responseType: "text"]',
      async run(fetch) {
        const url = `${HB}/status/200`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET json [responseType: "json"]',
      async run(fetch) {
        const url = `${HB}/status/200`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET json [responseType: "document"]',
      async run(fetch) {
        const url = `${HB}/status/200`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET json [responseType: "stream"]',
      async run(fetch) {
        const url = `${HB}/status/200`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET json [responseType: "arraybuffer"]',
      async run(fetch) {
        const url = `${HB}/status/200`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET json [responseType: "blob"]',
      async run(fetch) {
        const url = `${HB}/status/200`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET bytes [responseType: undefined]',
      async run(fetch) {
        const url = `${HB}/bytes/32`;
        const { res } = await gmRequest({
          method: 'GET',
          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');
      }
    },
    {
      name: 'GET bytes [responseType: ""]',
      async run(fetch) {
        const url = `${HB}/bytes/32`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET bytes [responseType: "text"]',
      async run(fetch) {
        const url = `${HB}/bytes/32`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET bytes [responseType: "json"]',
      async run(fetch) {
        const url = `${HB}/bytes/32`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET bytes [responseType: "document"]',
      async run(fetch) {
        const url = `${HB}/bytes/32`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET bytes [responseType: "stream"]',
      async run(fetch) {
        const url = `${HB}/bytes/32`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET bytes [responseType: "arraybuffer"]',
      async run(fetch) {
        const url = `${HB}/bytes/32`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET bytes [responseType: "blob"]',
      async run(fetch) {
        const url = `${HB}/bytes/32`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          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');
      }
    },
    {
      name: 'GET basic + headers + finalUrl',
      async run(fetch) {
        const url = `${HB}/get?x=1`;
        const { res } = await gmRequest({
          method: 'GET',
          url,
          headers: { 'X-Custom': 'Hello', 'Accept': 'application/json' },
          fetch
        });
        const body = JSON.parse(res.responseText);
        assertEq(res.status, 200, 'status 200');
        const q = getQueryObj(body);
        assertEq(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');
      }
    },
    {
      name: 'Redirect handling (finalUrl changes) [default]',
      async run(fetch) {
        const target = `${HB}/get?z=92`;
        const url = `${HB}/redirect-to?url=${encodeURIComponent(target)}`;
        const { res } = await gmRequest({
          method: 'GET', url,
          fetch
        });
        assertEq(res.status, 200, 'status after redirect is 200');
        assertEq(res.finalUrl, target, 'finalUrl is redirected target');
      }
    },
    {
      name: 'Redirect handling (finalUrl changes) [follow]',
      async run(fetch) {
        const target = `${HB}/get?z=94`;
        const url = `${HB}/redirect-to?url=${encodeURIComponent(target)}`;
        const { res } = await gmRequest({
          method: 'GET', url,
          redirect: "follow",
          fetch
        });
        assertEq(res.status, 200, 'status after redirect is 200');
        assertEq(res.finalUrl, target, 'finalUrl is redirected target');
      }
    },
    {
      name: 'Redirect handling (finalUrl changes) [error]',
      async run(fetch) {

        const target = `${HB}/get?z=96`;
        const url = `${HB}/redirect-to?url=${encodeURIComponent(target)}`;

        let res;
        try {
          res = await Promise.race([
            gmRequest({
              method: 'GET',
              url,
              redirect: "error",
              fetch
            }),
            new Promise(resolve => setTimeout(resolve, 4000))
          ]);
          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");
        }
      }
    },
    {
      name: 'Redirect handling (finalUrl changes) [manual]',
      async run(fetch) {

        const target = `${HB}/get?z=98`;
        const url = `${HB}/redirect-to?url=${encodeURIComponent(target)}`;

        const { res } = await Promise.race([
          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");
      }
    },
    {
      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');
      }
    },
    {
      name: 'POST JSON body',
      async run(fetch) {
        const payload = { alpha: 123, beta: 'hey' };
        const { res } = await gmRequest({
          method: 'POST',
          url: `${HB}/post`,
          headers: { 'Content-Type': 'application/json' },
          data: JSON.stringify(payload),
          fetch
        });
        const body = JSON.parse(res.responseText);
        assertEq(res.status, 200);
        assertDeepEq(body.json, payload, 'JSON echo matches');
      }
    },
    {
      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');
      }
    },
    {
      name: 'responseType=arraybuffer (download bytes)',
      async run(fetch) {
        let progressCounter = 0;
        const size = 40; // MAX 90
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/bytes/${size}`,
          responseType: 'arraybuffer',
          onprogress() {
            progressCounter++;
          },
          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");
      }
    },
    {
      name: 'responseType=blob',
      async run(fetch) {
        let progressCounter = 0;
        const size = 40; // MAX 90
        // httpbun doesn't have /image/png; use /bytes to ensure blob download
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/bytes/${size}`,
          responseType: 'blob',
          onprogress() {
            progressCounter++;
          },
          fetch
        });
        assertEq(res.status, 200);
        assert(res.response instanceof Blob, 'blob present');
        const buf = await res.response.arrayBuffer();
        assertEq(buf.byteLength, size, 'byte length matches');
        assert(progressCounter >= 1, "progressCounter >= 1");
        // Do not assert image MIME; httpbun returns octet-stream here.
      }
    },
    {
      name: 'responseType=json',
      async run(fetch) {
        // Use /ip which returns JSON
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/ip`,
          responseType: 'json',
          fetch
        });
        assertEq(res.status, 200);
        assert(res.response && typeof res.response === 'object', 'parsed JSON object');
        assert(res.response.origin, 'has JSON fields');
      }
    },
    {
      name: 'overrideMimeType (force text)',
      async run(fetch) {
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/ip`,
          overrideMimeType: 'text/plain;charset=utf-8',
          fetch
        });
        assertEq(res.status, 200);
        assert(typeof res.responseText === 'string' && res.responseText.length > 0, 'responseText available');
      }
    },
    {
      name: 'Timeout + ontimeout',
      async run(fetch) {
        try {
          await gmRequest({
            method: 'GET',
            url: `${HB}/delay/3`, // waits ~3s
            timeout: 1000,
            fetch
          });
          throw new Error('Expected timeout, got load');
        } catch (e) {
          assertEq(e.kind, 'timeout', 'timeout path taken');
        }
      }
    },
    {
      name: 'onprogress fires while downloading [arraybuffer]',
      async run(fetch) {
        let progressEvents = 0;
        let lastLoaded = 0;
        let response = null;
        // Use drip endpoint to stream bytes
        const { res } = await new Promise((resolve, reject) => {
          const start = performance.now();
          GM_xmlhttpRequest({
            method: 'GET',
            url: `${HB}/drip?duration=2&delay=1&numbytes=1024`, // ~1KB
            responseType: 'arraybuffer',
            onprogress: (ev) => {
              progressEvents++;
              if (ev.loaded != null) lastLoaded = ev.loaded;
              setStatus(`downloading: ${lastLoaded | 0} bytes…`);
              response = ev.response;
            },
            onload: (res) => resolve({ res, ms: performance.now() - start }),
            onerror: (res) => reject({ kind: 'error', res }),
            ontimeout: (res) => reject({ kind: 'timeout', res }),
            fetch
          });
        });
        assertEq(res.status, 200);
        assert(progressEvents >= 4, 'received at least 4 progress events');
        assert(lastLoaded >= 0, 'progress loaded captured');
        assert(!response, 'no response');
      }
    },
    {
      name: 'onprogress fires while downloading [stream]',
      async run(fetch) {
        let progressEvents = 0;
        let lastLoaded = 0;
        let response = null;
        // Use drip endpoint to stream bytes
        const { res } = await new Promise((resolve, reject) => {
          const start = performance.now();
          GM_xmlhttpRequest({
            method: 'GET',
            url: `${HB}/drip?duration=2&delay=1&numbytes=1024`, // ~1KB
            responseType: 'stream',
            onloadstart: async (ev) => {
              const reader = ev.response?.getReader();
              if (reader) {
                let loaded = 0;
                while (true) {
                  const { done, value } = await reader.read(); // value is Uint8Array
                  if (value) {
                    progressEvents++;
                    loaded += value.length;
                    if (loaded != null) lastLoaded = loaded;
                    setStatus(`downloading: ${loaded | 0} bytes…`);
                    response = ev.response;
                  }
                  if (done) break;
                }
              }
            },
            onloadend: (res) => resolve({ res, ms: performance.now() - start }),
            onerror: (res) => reject({ kind: 'error', res }),
            ontimeout: (res) => reject({ kind: 'timeout', res }),
            fetch
          });
        });
        assertEq(res.status, 200);
        assert(progressEvents >= 4, 'received at least 4 progress events');
        assert(lastLoaded >= 0, 'progress loaded captured');
        assert(response instanceof ReadableStream && typeof response.getReader === "function", 'response');
      }
    },
    {
      name: 'HEAD request - ensure body exist',
      async run(fetch) {
        const { res } = await gmRequest({
          method: 'GET',
          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');
      }
    },
    {
      name: 'HEAD request - without body',
      async run(fetch) {
        const { res } = await gmRequest({
          method: 'HEAD',
          url: `${HB}/response-headers`,
          fetch
        });
        assertEq(res.status, 200);
        assertEq(res.responseText || '', '', 'no body for HEAD');
        assert(typeof res.responseHeaders === 'string', 'response headers present');
      }
    },
    {
      name: 'OPTIONS request',
      async run(fetch) {
        const { res } = await gmRequest({
          method: 'OPTIONS',
          url: `${HB}/any`,
          fetch
        });
        // httpbun commonly returns 200 for OPTIONS
        assert(res.status === 200 || res.status === 204, '200/204 on OPTIONS');
      }
    },
    {
      name: 'DELETE request',
      async run(fetch) {
        const { res } = await gmRequest({
          method: 'DELETE',
          url: `${HB}/delete`,
          fetch
        });
        assertEq(res.status, 200);
        const body = JSON.parse(res.responseText);
        assertEq(body.method, 'DELETE', 'server saw DELETE');
      }
    },
    {
      name: 'anonymous TEST - set cookie "abc"',
      async run(fetch) {
        // httpbin echoes Cookie header in headers
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/cookies/set/abc/123`,
          fetch
        });
      }
    },
    {
      name: 'anonymous TEST - get cookie',
      async run(fetch) {
        // httpbin echoes Cookie header in headers
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/cookies`,
          fetch
        });
        assertEq(res.status, 200);
        const body = JSON.parse(res.responseText);
        const cookieABC = body.cookies.abc;
        assertEq(cookieABC, "123", 'cookie abc=123');
      }
    },
    {
      name: 'anonymous: true (no cookies sent)',
      async run(fetch) {
        // httpbin echoes Cookie header in headers
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/headers`,
          anonymous: true,
          fetch
        });
        const body = JSON.parse(res.responseText);
        const cookies = body.headers.Cookie || body.headers.cookie;
        assert(!`${cookies}`.includes("abc=123"), 'no Cookie header when anonymous');
      }
    },
    {
      name: 'anonymous: false (cookies sent)',
      async run(fetch) {
        // httpbin echoes Cookie header in headers
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/headers`,
          fetch
        });
        const body = JSON.parse(res.responseText);
        const cookies = body.headers.Cookie || body.headers.cookie;
        assert(`${cookies}`.includes("abc=123"), 'Cookie header');
      }
    },
    {
      name: 'anonymous TEST - delete cookies',
      async run(fetch) {
        // httpbin echoes Cookie header in headers
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/cookies/delete`,
          anonymous: true,
          fetch
        });
      }
    },
    {
      name: 'anonymous: true[2] - set cookie "def"',
      async run(fetch) {
        // httpbin echoes Cookie header in headers
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/cookies/set/def/456`,
          anonymous: true,
          fetch
        });
      }
    },
    {
      name: 'anonymous: true[2] (no cookies sent)',
      async run(fetch) {
        // httpbin echoes Cookie header in headers
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/headers`,
          anonymous: true,
          fetch
        });
        const body = JSON.parse(res.responseText);
        const cookies = body.headers.Cookie || body.headers.cookie;
        assert(!cookies, 'no Cookie header when anonymous');
      }
    },
    {
      name: 'anonymous TEST - delete cookies',
      async run(fetch) {
        // httpbin echoes Cookie header in headers
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/cookies/delete`,
          anonymous: true,
          fetch
        });
      }
    },
    {
      name: 'Basic auth with user/password',
      async run(fetch) {
        const user = 'user', pass = 'passwd';
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/basic-auth/${user}/${pass}`,
          user, password: pass,
          fetch
        });
        assertEq(res.status, 200);
        const body = JSON.parse(res.responseText);
        assertEq(body.authenticated, true, 'authenticated true');
        assertEq(body.user, 'user', 'user echoed');
      }
    },
    {
      name: 'Non-2xx stays in onload (status 418)',
      async run(fetch) {
        const { res } = await gmRequest({
          method: 'GET',
          url: `${HB}/status/418`,
          fetch
        });
        assertEq(res.status, 418, '418 I\'m a teapot');
        // Still triggers onload, not onerror
      }
    },
    {
      name: 'Invalid method -> expected server 405 or 200 echo',
      async run(fetch) {
        // httpbun accepts any method on /headers (per docs), so status may be 200
        const { res } = await gmRequest({
          method: 'FOOBAR',
          url: `${HB}/headers`,
          fetch
        });
        assert([200, 405].includes(res.status), '200 or 405 depending on server handling');
      }
    },
    {
      name: 'onerror for blocked domain (missing @connect) [https]',
      async run(fetch) {
        // We did not include @connect for example.org; Tampermonkey should block and call onerror.
        try {
          await gmRequest({
            method: 'GET', url: 'https://example.org/',
            fetch
          });
          throw new Error('Expected onerror due to @connect, but got onload');
        } catch (e) {
          assertEq(e.kind, 'error', 'onerror path taken');
          assert(e.res, 'e.res exists');
          assertEq(e.res.status, 0, 'status 0');
          assertEq(e.res.statusText, "", 'statusText ""');
          assertEq(e.res.finalUrl, undefined, 'finalUrl undefined');
          assertEq(e.res.readyState, 4, 'readyState DONE');
          assertEq(!e.res.response, true, '!response ok');
          assertEq(e.res.responseText, "", 'responseText ""');
          assertEq(e.res.responseXML, undefined, 'responseXML undefined');
          assertEq(typeof (e.res.error || undefined), "string", 'error set');
          assertEq(`${e.res.error}`.includes(`Refused to connect to "https://example.org/": `), true, 'Refused to connect to ...');
        }
      }
    },
    {
      name: 'onerror for blocked domain (missing @connect) [http]',
      async run(fetch) {
        try {
          await gmRequest({
            method: 'GET', url: 'http://domain-abcxyz.test/',
            fetch
          });
          throw new Error('Expected error, got load');
        } catch (e) {
          assertEq(e.kind, 'error', 'onerror path taken');
          assert(e.res, 'e.res exists');
          assertEq(e.res.status, 0, 'status 0');
          assertEq(e.res.statusText, "", 'statusText ""');
          assertEq(e.res.finalUrl, undefined, 'finalUrl undefined');
          assertEq(e.res.readyState, 4, 'readyState DONE');
          assertEq(!e.res.response, true, '!response ok');
          assertEq(e.res.responseText, "", 'responseText ""');
          assertEq(e.res.responseXML, undefined, 'responseXML undefined');
          assertEq(typeof (e.res.error || undefined), "string", 'error set');
          assertEq(`${e.res.error}`.includes(`Refused to connect to "http://domain-abcxyz.test/": `), true, 'Refused to connect to ...');
        }
      }
    },
    {
      name: 'onerror for DNS failure',
      async run(fetch) {
        try {
          await gmRequest({
            method: 'GET', url: 'https://nonexistent-domain-abcxyz.test/',
            fetch
          });
          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');
        }
      }
    },
    {
      name: 'Manual abort + onabort',
      async run(fetch) {
        try {
          await Promise.race([
            gmRequest({
              method: 'GET',
              url: `${HB}/delay/5`,
              fetch
            }, { abortAfterMs: 200 }),
            new Promise(resolve => setTimeout(resolve, 800))
          ]);
          throw new Error('Expected abort, got load');
        } catch (e) {
          assertEq(e.kind, 'abort', 'abort path taken');
        }
      }
    }
  ];

  const tests = [
    ...basicTests,
    ...basicTests.map((item) => {
      return { ...item, useFetch: true };
    })
  ];

  // ---------- Assertion utils ----------
  function assert(condition, msg) {
    if (!condition) throw new Error(msg || 'assertion failed');
  }
  function assertEq(a, b, msg) {
    if (a !== b) throw new Error(msg ? `${msg}: expected ${b}, got ${a}` : `expected ${b}, got ${a}`);
  }
  function assertDeepEq(a, b, msg) {
    const aj = JSON.stringify(a);
    const bj = JSON.stringify(b);
    if (aj !== bj) throw new Error(msg ? `${msg}: expected ${bj}, got ${aj}` : `deep equal failed`);
  }
  function getHeader(headersStr, key) {
    const lines = (headersStr || '').split(/\r?\n/);
    const line = lines.find(l => l.toLowerCase().startsWith(key.toLowerCase() + ':'));
    return line ? line.split(':').slice(1).join(':').trim() : '';
  }

  // ---------- 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(`<b>Starting GM_xmlhttpRequest test suite</b> — ${new Date().toLocaleString()}`);

    for (let i = 0; i < tests.length; i++) {
      const t = tests[i];
      const title = `• ${t.name}`;
      const t0 = performance.now();
      setStatus(`running (${i + 1}/${tests.length}): ${t.name}`);
      try {
        logLine(`▶️ <b>${escapeHtml(t.name)}</b> (queued: ${tests.length - i - 1} remaining)`);
        await t.run(t.useFetch ? true : false);
        pass(`${title}  (${fmtMs(performance.now() - t0)})`);
      } catch (e) {
        const extra = e && e.stack ? e.stack : String(e);
        fail(`${title}  (${fmtMs(performance.now() - t0)})`, extra);
      } finally {
        // update pending list
        setQueue(names.slice(i + 1));
      }
    }

    setStatus('done');
    logLine(`<b>Done.</b> Summary — ✅ ${state.pass}${state.fail}${state.skip}`);
  }

  function fmtMs(ms) {
    return ms < 1000 ? `${ms | 0}ms` : `${(ms / 1000).toFixed(2)}s`;
  }

  // Auto-run once after a short delay to let the page settle
  setTimeout(() => {
    // Only auto-run if not already run in this page session
    if (!window.__gmxhr_test_autorun__) {
      window.__gmxhr_test_autorun__ = true;
      runAll();
    }
  }, 600);

})();

@cyfung1031
Copy link
Collaborator

cyfung1031 commented Dec 15, 2025

allowResponse 测试用脚本

  • onreadystatechange 0->1->2->3->onprogress#1 (readyState:3)->onprogress#2 (readyState:4)->onreadystatechange (readyState:4)

  • 因此条件设为 if ((res.readyState === 4 || reqDone) && res.eventType !== "progress") allowResponse = true;

xhr

// ==UserScript==
// @name         New Userscript M34
// @namespace    http://tampermonkey.net/
// @version      2025-12-15
// @description  try to take over the world!
// @author       You
// @match        https://example.com/?m34sc
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        GM_xmlhttpRequest
// @connect      raw.githubusercontent.com
// ==/UserScript==

(function() {
    'use strict';

    const log = (type, response) => {
        console.log("-------------------------------");
        console.log({
            type: type,
            readyState: response.readyState,
            status: response.status,
            responseText: typeof response.responseText,
            response: typeof response.response,
        });
        console.log(response.responseText);
        console.log(response.response);
        console.log("-------------------------------");
    }


    GM_xmlhttpRequest({
        method: "GET",
        url: "https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
        onreadystatechange: function (response) {
            log("onreadystatechange", response);
        },
        onprogress: function (response) {
            log("onprogress", response);
        },
        onload: function (response) {
            log("onload", response);
        }
    });


    // Your code here...
})();

TM

Screenshot 2025-12-15 at 23 24 20

SC

Screenshot 2025-12-15 at 23 24 44

fetchXhr

// ==UserScript==
// @name         New Userscript M34
// @namespace    http://tampermonkey.net/
// @version      2025-12-15
// @description  try to take over the world!
// @author       You
// @match        https://example.com/?m34tm
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        GM_xmlhttpRequest
// @connect      raw.githubusercontent.com
// ==/UserScript==

(function() {
    'use strict';

    const log = (type, response) => {
        console.log("-------------------------------");
        console.log({
            type: type,
            readyState: response.readyState,
            status: response.status,
            responseText: typeof response.responseText,
            response: typeof response.response,
        });
        console.log(response.responseText);
        console.log(response.response);
        console.log("-------------------------------");
    }


    GM_xmlhttpRequest({
        method: "GET",
        anonymous: true,
        url: "https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
        onreadystatechange: function (response) {
            log("onreadystatechange", response);
        },
        onprogress: function (response) {
            log("onprogress", response);
        },
        onload: function (response) {
            log("onload", response);
        }
    });


    // Your code here...
})();

TM

Screenshot 2025-12-15 at 23 26 54

SC

Screenshot 2025-12-15 at 23 29 20

你可以加入 responseType:"json", 数据大小是一致的
只是不知为何 SC 的 fetchXhr onprogress 次数较少
不过这次数没有什么规范可言。不影响的

@cyfung1031
Copy link
Collaborator

cyfung1031 commented Dec 16, 2025

JSON Fallback Test

xhr

// ==UserScript==
// @name         New Userscript M35
// @namespace    http://tampermonkey.net/
// @version      2025-12-15
// @description  try to take over the world!
// @author       You
// @match        https://example.com/?m35sc
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        GM_xmlhttpRequest
// @connect      raw.githubusercontent.com
// ==/UserScript==

(function() {
    'use strict';

    const log = (type, response) => {
        console.log("-------------------------------");
        console.log({
            type: type,
            code: `r${response.readyState}s${response.status}`,
            responseText: `${"responseText" in response ? 1 : 0}:${typeof response.responseText}`,
            response: `${"response" in response ? 1 : 0}:${typeof response.response}`,
            responseXML: `${"responseXML" in response ? 1 : 0}:${typeof response.responseXML}`,
        });
        console.log(response.responseText);
        console.log(response.response);
        console.log("-------------------------------");
    }


    GM_xmlhttpRequest({
        method: "GET",
        responseType: "json",
        url: "https://raw.githubusercontent.com/mdn/content/main/files/en-us/_redirects.txt",
        onreadystatechange: function (response) {
            log("onreadystatechange", response);
        },
        onprogress: function (response) {
            log("onprogress", response);
        },
        onload: function (response) {
            log("onload", response);
        },
        onloadstart: function (response) {
            log("onloadstart", response);
        },
    });


    // Your code here...
})();

TM

Screenshot 2025-12-16 at 9 45 29

SC

Screenshot 2025-12-16 at 9 45 06

fetchXhr

// ==UserScript==
// @name         New Userscript M35
// @namespace    http://tampermonkey.net/
// @version      2025-12-15
// @description  try to take over the world!
// @author       You
// @match        https://example.com/?m35sc
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant        GM_xmlhttpRequest
// @connect      raw.githubusercontent.com
// ==/UserScript==

(function() {
    'use strict';

    const log = (type, response) => {
        console.log("-------------------------------");
        console.log({
            type: type,
            code: `r${response.readyState}s${response.status}`,
            responseText: `${"responseText" in response ? 1 : 0}:${typeof response.responseText}`,
            response: `${"response" in response ? 1 : 0}:${typeof response.response}`,
            responseXML: `${"responseXML" in response ? 1 : 0}:${typeof response.responseXML}`,
        });
        console.log(response.responseText);
        console.log(response.response);
        console.log("-------------------------------");
    }


    GM_xmlhttpRequest({
        method: "GET",
        anonymous: true,
        responseType: "json",
        url: "https://raw.githubusercontent.com/mdn/content/main/files/en-us/_redirects.txt",
        onreadystatechange: function (response) {
            log("onreadystatechange", response);
        },
        onprogress: function (response) {
            log("onprogress", response);
        },
        onload: function (response) {
            log("onload", response);
        },
        onloadstart: function (response) {
            log("onloadstart", response);
        },
    });


    // Your code here...
})();

TM

Screenshot 2025-12-16 at 9 41 37

SC

Screenshot 2025-12-16 at 9 40 22

@CodFrm
Copy link
Member Author

CodFrm commented Dec 16, 2025

GM_xmlhttpRequest Exhaustive Test Harness v2 可以同步到之前的测试脚本吗?

example/tests/gm_xhr_test.js

TM在readyState 4 之前都不会有 response, responseText, responseXML

那感觉我之前的修复方法也没有什么大问题

@cyfung1031
Copy link
Collaborator

GM_xmlhttpRequest Exhaustive Test Harness v2 可以同步到之前的测试脚本吗?

example/tests/gm_xhr_test.js

暂时未有修改
之后可以加一下这里的新测试

这个PR的内容已经改好的。
你可以测试一下真实脚本。没问题可以发布1.2.3

@cyfung1031 cyfung1031 removed their assignment Dec 16, 2025
@CodFrm CodFrm merged commit 3d987c3 into main Dec 16, 2025
2 of 3 checks passed
@cyfung1031
Copy link
Collaborator

oh. 合併了
我另外開PR吧

cyfung1031 added a commit to cyfung1031/scriptcat that referenced this pull request Dec 19, 2025
* 处理GM xhr的问题

* 补上单元测试

* 恢复之前的单元测试

* 增加 readyState 判断 修复 responseText 问题

* 删除调试日志

* `${Date.now}` -> `${Date.now()}`

* 修复fetch模式下,触发 readyState==1的问题

* fetch行为与TM保持一致

* 调整测试

* 修改错误的单元测试

* GMXhr 代码修正

* 中文

* 中文

* parseType 代码改善

* 调整代码

* 加入 allowResponse 特殊处理:readyState 达至 4 之前,排除 response, responseText, responseXML

* `res.readyState === 4` -> `res.readyState === 4 || reqDone`

* Update gm_xhr.ts

* 调整代码

* typescript 调整

* 增加逻辑控制保护

* typescript 调整

* 处理共用

* 整理代码

* 修复错误

---------

Co-authored-by: cyfung1031 <44498510+cyfung1031@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

hotfix 需要尽快更新到扩展商店 P0 🚑 需要紧急处理的内容

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] 使用网盘转直链脚本,提示“获取下载链接失败,刷新网页后再试试吧~”

3 participants