From 08eec9f4bc4420a452ae6dfa73063eb62503ef73 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 17:14:51 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9C=A8=20=E4=B8=BA=20GM=5FxmlhttpReque?= =?UTF-8?q?st=20/=20GM.xmlHttpRequest=20=E5=AE=9E=E7=8E=B0=20upload=20?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E8=BF=9B=E5=BA=A6=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 Tampermonkey:details.upload 可挂载 onloadstart/onprogress/onload/ onloadend/onerror/onabort/ontimeout,对应原生 XMLHttpRequestUpload 事件。 仅原生 XHR 路径支持(fetch 模式无法获取上传进度,维持既有限制)。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/content/gm_api/gm_xhr.test.ts | 139 ++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 32 ++++ src/pkg/utils/xhr/bg_gm_xhr.test.ts | 148 ++++++++++++++++++ src/pkg/utils/xhr/bg_gm_xhr.ts | 32 ++++ src/types/scriptcat.d.ts | 13 ++ src/types/scriptcat.zh-CN.d.ts | 13 ++ tests/runtime/gm_api.test.ts | 26 +++ 7 files changed, 403 insertions(+) create mode 100644 src/app/service/content/gm_api/gm_xhr.test.ts create mode 100644 src/pkg/utils/xhr/bg_gm_xhr.test.ts diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts new file mode 100644 index 000000000..c7fd93574 --- /dev/null +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import { GM_xmlhttpRequest } from "./gm_xhr"; +import type GMApi from "./gm_api"; +import type { MessageConnect, TMessage } from "@Packages/message/types"; + +const waitTick = async (times = 3) => { + for (let i = 0; i < times; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +}; + +function createFakeApi(): { api: GMApi; getMessageHandler: () => ((data: TMessage) => void) | undefined } { + let messageHandler: ((data: TMessage) => void) | undefined; + const fakeConnect: MessageConnect = { + onMessage: (cb) => { + messageHandler = cb; + }, + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + }; + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(fakeConnect), + sendMessage: vi.fn(), + } as unknown as GMApi; + return { api, getMessageHandler: () => messageHandler }; +} + +describe("GM_xmlhttpRequest 的 upload 事件派发", () => { + it("收到 onuploadprogress 消息时,应携带 loaded/total/lengthComputable 调用 details.upload.onprogress", async () => { + const { api, getMessageHandler } = createFakeApi(); + const onprogress = vi.fn(); + const details = { + url: "https://example.com/upload", + upload: { onprogress }, + } as unknown as GMTypes.XHRDetails; + + GM_xmlhttpRequest(api, details, false); + await waitTick(); + const messageHandler = getMessageHandler(); + expect(messageHandler).toBeTypeOf("function"); + + messageHandler!({ + action: "onuploadprogress", + data: { + finalUrl: "", + readyState: 1, + status: 0, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "uploadprogress", + ok: false, + contentType: "", + loaded: 30, + total: 120, + lengthComputable: true, + }, + }); + await waitTick(); + + expect(onprogress).toHaveBeenCalledTimes(1); + const arg = onprogress.mock.calls[0][0]; + expect(arg.loaded).toBe(30); + expect(arg.total).toBe(120); + expect(arg.lengthComputable).toBe(true); + }); + + it.each([ + ["onuploadloadstart", "onloadstart"], + ["onuploadload", "onload"], + ["onuploadloadend", "onloadend"], + ["onuploaderror", "onerror"], + ["onuploadabort", "onabort"], + ["onuploadtimeout", "ontimeout"], + ])("收到 %s 消息时,应调用 details.upload.%s", async (action, uploadHandlerName) => { + const { api, getMessageHandler } = createFakeApi(); + const handler = vi.fn(); + const details = { + url: "https://example.com/upload", + upload: { [uploadHandlerName]: handler }, + } as unknown as GMTypes.XHRDetails; + + GM_xmlhttpRequest(api, details, false); + await waitTick(); + const messageHandler = getMessageHandler(); + expect(messageHandler).toBeTypeOf("function"); + + messageHandler!({ + action, + data: { + finalUrl: "", + readyState: 1, + status: 0, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: action.slice(2), + ok: false, + contentType: "", + }, + }); + await waitTick(); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("不应影响主响应回调(onload 仍正常派发)", async () => { + const { api, getMessageHandler } = createFakeApi(); + const onload = vi.fn(); + const details = { + url: "https://example.com/upload", + onload, + } as unknown as GMTypes.XHRDetails; + + GM_xmlhttpRequest(api, details, false); + await waitTick(); + const messageHandler = getMessageHandler(); + + messageHandler!({ + action: "onload", + data: { + finalUrl: "https://example.com/upload", + readyState: 4, + status: 200, + statusText: "OK", + responseHeaders: "", + useFetch: false, + eventType: "load", + ok: true, + contentType: "text/plain", + }, + }); + await waitTick(); + + expect(onload).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index d3b147f88..a5ffa5d0d 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -703,6 +703,38 @@ export function GM_xmlhttpRequest( case "onabort": doAbort?.(data); break; + case "onuploadloadstart": + details.upload?.onloadstart?.(makeXHRCallbackParam?.(data) ?? {}); + break; + case "onuploadprogress": { + if (details.upload?.onprogress) { + const res = { + ...(makeXHRCallbackParam?.(data) ?? {}), + lengthComputable: data.lengthComputable as boolean, + loaded: data.loaded as number, + total: data.total as number, + done: data.loaded, + totalSize: data.total, + }; + details.upload.onprogress?.(res); + } + break; + } + case "onuploadload": + details.upload?.onload?.(makeXHRCallbackParam?.(data) ?? {}); + break; + case "onuploadloadend": + details.upload?.onloadend?.(makeXHRCallbackParam?.(data) ?? {}); + break; + case "onuploaderror": + details.upload?.onerror?.(makeXHRCallbackParam?.(data) ?? {}); + break; + case "onuploadabort": + details.upload?.onabort?.(makeXHRCallbackParam?.(data) ?? {}); + break; + case "onuploadtimeout": + details.upload?.ontimeout?.(makeXHRCallbackParam?.(data) ?? {}); + break; // case "onstream": // controller?.enqueue(new Uint8Array(data)); // break; diff --git a/src/pkg/utils/xhr/bg_gm_xhr.test.ts b/src/pkg/utils/xhr/bg_gm_xhr.test.ts new file mode 100644 index 000000000..ca7e78083 --- /dev/null +++ b/src/pkg/utils/xhr/bg_gm_xhr.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { MessageConnect } from "@Packages/message/types"; +import { BgGMXhr } from "./bg_gm_xhr"; + +class FakeXMLHttpRequest { + static instances: FakeXMLHttpRequest[] = []; + + readyState = 0; + status = 0; + statusText = ""; + responseURL = ""; + responseType = ""; + response: unknown = null; + responseText = ""; + timeout = 0; + withCredentials = false; + upload: Record void) | null> = {}; + + onreadystatechange: ((evt: any) => void) | null = null; + onloadstart: ((evt: any) => void) | null = null; + onload: ((evt: any) => void) | null = null; + onloadend: ((evt: any) => void) | null = null; + onerror: ((evt: any) => void) | null = null; + onprogress: ((evt: any) => void) | null = null; + onabort: ((evt: any) => void) | null = null; + ontimeout: ((evt: any) => void) | null = null; + + sentData: unknown; + + constructor() { + FakeXMLHttpRequest.instances.push(this); + } + + open(_method: string, url: string) { + this.responseURL = url; + } + + setRequestHeader() {} + + overrideMimeType() {} + + getResponseHeader(): string | null { + return null; + } + + getAllResponseHeaders(): string { + return ""; + } + + send(data: unknown) { + this.sentData = data; + } + + abort() {} +} + +const waitTick = async (times = 2) => { + for (let i = 0; i < times; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +}; + +describe("BgGMXhr 的 upload 事件转发", () => { + let originalXHR: typeof XMLHttpRequest; + let msgConn: MessageConnect; + let sentMessages: { action: string; data: any }[]; + + beforeEach(() => { + FakeXMLHttpRequest.instances = []; + sentMessages = []; + msgConn = { + onMessage: vi.fn(), + sendMessage: vi.fn((data: any) => { + sentMessages.push(data); + }), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + }; + originalXHR = global.XMLHttpRequest; + // @ts-expect-error 测试用假 XMLHttpRequest 替换全局实现 + global.XMLHttpRequest = FakeXMLHttpRequest; + }); + + afterEach(() => { + global.XMLHttpRequest = originalXHR; + }); + + const createXhr = async () => { + const details = { method: "POST", url: "https://example.com/upload" } as unknown as GMSend.XHRDetails; + const bgGmXhr = new BgGMXhr( + details, + { statusCode: 0, finalUrl: "", responseHeaders: "" }, + msgConn, + null, + "marker-1" + ); + bgGmXhr.do(); + await waitTick(); + const xhr = FakeXMLHttpRequest.instances[0]; + expect(xhr).toBeDefined(); + return xhr; + }; + + it("原生 XHR 的 upload.onprogress 触发时,应发送携带进度数据的 onuploadprogress 消息", async () => { + const xhr = await createXhr(); + expect(typeof xhr.upload.onprogress).toBe("function"); + + xhr.readyState = 1; + xhr.upload.onprogress?.({ type: "progress", loaded: 30, total: 120, lengthComputable: true }); + await waitTick(); + + const msg = sentMessages.find((m) => m.action === "onuploadprogress"); + expect(msg).toBeDefined(); + expect(msg!.data.loaded).toBe(30); + expect(msg!.data.total).toBe(120); + expect(msg!.data.lengthComputable).toBe(true); + }); + + it.each([ + ["loadstart", "onuploadloadstart"], + ["load", "onuploadload"], + ["loadend", "onuploadloadend"], + ["error", "onuploaderror"], + ["abort", "onuploadabort"], + ["timeout", "onuploadtimeout"], + ])("原生 XHR 的 upload.on%s 触发时,应发送 %s 消息", async (nativeType, expectedAction) => { + const xhr = await createXhr(); + const handlerName = `on${nativeType}` as keyof FakeXMLHttpRequest["upload"]; + expect(typeof xhr.upload[handlerName]).toBe("function"); + + xhr.upload[handlerName]?.({ type: nativeType, loaded: 10, total: 10, lengthComputable: true }); + await waitTick(); + + const msg = sentMessages.find((m) => m.action === expectedAction); + expect(msg).toBeDefined(); + }); + + it("不应影响主响应事件的消息转发(onload 仍正常发送)", async () => { + const xhr = await createXhr(); + xhr.readyState = 4; + xhr.status = 200; + xhr.onload?.({ type: "load" }); + await waitTick(); + + const msg = sentMessages.find((m) => m.action === "onload"); + expect(msg).toBeDefined(); + }); +}); diff --git a/src/pkg/utils/xhr/bg_gm_xhr.ts b/src/pkg/utils/xhr/bg_gm_xhr.ts index e497e662f..083c330f4 100644 --- a/src/pkg/utils/xhr/bg_gm_xhr.ts +++ b/src/pkg/utils/xhr/bg_gm_xhr.ts @@ -83,6 +83,7 @@ const isProgressEvent = (e: XHREvent): e is ProgressEvent & { type: * | `onreadystatechange(response)` | Called when the request’s `readyState` changes. | * | `ontimeout(response)` | Called if the request times out. | * | `onload(response)` | Called when the request successfully completes. | + * | `upload` | Object probed for `onloadstart`/`onprogress`/`onload`/`onloadend`/`onerror`/`onabort`/`ontimeout`, mirroring `XMLHttpRequestUpload`. Not supported when `fetch: true`. | * * --- * ### Response Object @@ -392,6 +393,37 @@ export class BgGMXhr { baseXHR.ontimeout = callback; baseXHR.onreadystatechange = callback; baseXHR.onloadend = callback; + + // upload 阶段事件:仅原生 XMLHttpRequest 提供 xhr.upload,fetch 模式不支持上传进度回调 + if (!(localThis instanceof FetchXHR)) { + const uploadCallback = (evt: ProgressEvent) => { + const xhr = localThis; + const eventType = `upload${evt.type}`; + this.callback({ + useFetch: useFetch, + eventType, + ok: xhr.status >= 200 && xhr.status < 300, + contentType, + readyState: xhr.readyState as GMTypes.ReadyState, + status: xhr.status, + statusText: xhr.statusText, + responseHeaders, + responseURL: xhr.responseURL, + error: evt.type !== "error" ? undefined : "Unknown Error", + total: evt.total, + loaded: evt.loaded, + lengthComputable: evt.lengthComputable, + } satisfies BgGMXhrCallbackResult); + }; + const upload = localThis.upload; + upload.onloadstart = uploadCallback; + upload.onprogress = uploadCallback; + upload.onload = uploadCallback; + upload.onloadend = uploadCallback; + upload.onerror = uploadCallback; + upload.onabort = uploadCallback; + upload.ontimeout = uploadCallback; + } } baseXHR.open(details.method ?? "GET", url, true, details.user, details.password); diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 85a45b9d0..4bda98aa8 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -655,6 +655,17 @@ declare namespace GMTypes { type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; + /** Event handlers for the request's upload phase, mirroring `XMLHttpRequestUpload`. */ + interface XHRUpload { + onloadstart?: Listener; + onprogress?: Listener; + onload?: Listener; + onloadend?: Listener; + onerror?: Listener; + onabort?: Listener; + ontimeout?: Listener; + } + interface XHRDetails { method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; url: string | URL | File | Blob; @@ -683,6 +694,8 @@ declare namespace GMTypes { redirect?: "follow" | "error" | "manual"; /** Partitioned cookie key for storage partitioning. */ cookiePartition?: Record & { topLevelSite?: string }; + /** Event handlers for the upload phase, mirroring `XMLHttpRequestUpload`. */ + upload?: XHRUpload; onload?: Listener; onloadstart?: Listener; diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 4c99999cb..51fd0a649 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -661,6 +661,17 @@ declare namespace GMTypes { type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; + /** 上传阶段的事件处理器,对齐 `XMLHttpRequestUpload`。 */ + interface XHRUpload { + onloadstart?: Listener; + onprogress?: Listener; + onload?: Listener; + onloadend?: Listener; + onerror?: Listener; + onabort?: Listener; + ontimeout?: Listener; + } + interface XHRDetails { method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; url: string | URL | File | Blob; @@ -689,6 +700,8 @@ declare namespace GMTypes { redirect?: "follow" | "error" | "manual"; /** 分区 Cookie 键,用于存储分区。 */ cookiePartition?: Record & { topLevelSite?: string }; + /** 上传阶段的事件处理器,对齐 `XMLHttpRequestUpload`。 */ + upload?: XHRUpload; onload?: Listener; onloadstart?: Listener; diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index e1840b447..a5199e67a 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -420,6 +420,32 @@ describe.concurrent("GM xmlHttpRequest", () => { }); }); }); + it.concurrent("upload progress", async () => { + const body = "hello upload body"; + const bodySize = new TextEncoder().encode(body).length; + const onUploadProgress = vitest.fn(); + const onUploadLoad = vitest.fn(); + const onUploadLoadEnd = vitest.fn(); + await new Promise((resolve) => { + gmApi.GM_xmlhttpRequest({ + method: "POST", + url: "https://www.example.com/upload", + data: body, + upload: { + onprogress: onUploadProgress, + onload: onUploadLoad, + onloadend: onUploadLoadEnd, + }, + onloadend: () => resolve(), + }); + }); + expect(onUploadProgress).toBeCalled(); + const lastProgress = onUploadProgress.mock.calls[onUploadProgress.mock.calls.length - 1][0]; + expect(lastProgress.loaded).toBe(bodySize); + expect(lastProgress.total).toBe(bodySize); + expect(onUploadLoad).toBeCalledTimes(1); + expect(onUploadLoadEnd).toBeCalledTimes(1); + }); }); describe("GM download", () => { From b73a531975f7b487a4d24c7689bf99d1aec7fe40 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:45:51 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20uploa?= =?UTF-8?q?d=20=E4=BA=8B=E4=BB=B6=E7=9A=84=E9=A2=84=E6=A3=80=E5=BC=80?= =?UTF-8?q?=E9=94=80=E3=80=81abort=20=E9=81=97=E6=BC=8F=E4=B8=8E=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E5=AD=97=E6=AE=B5=E7=BC=BA=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 仅在脚本实际注册了 upload 回调时才为 xhr.upload 绑定监听器(GMSend.XHRDetails 新增 hasUpload 标记):为 xhr.upload 注册任何监听器都会令浏览器对跨域请求强制 触发 CORS 预检 (OPTIONS),未使用该功能的脚本不应承担这一开销/兼容性风险。 - 调用返回的 abort() 时,若 upload 阶段尚未完成,补发 details.upload.onabort / onloadend(对齐原生 XMLHttpRequest.abort() 语义);upload 阶段已完成后再次 abort 不会重复触发。放宽 abort() 的触发条件,使仅注册了 upload 回调(未注册 顶层 onabort)的脚本也能正常收到 abort 通知。 - 统一 upload 各回调 (onloadstart/onload/onloadend/onerror/onabort/ontimeout) 均携带 loaded/total/lengthComputable(对齐 XHR 规范:upload 的事件均为 ProgressEvent),类型定义同步放宽为 Listener。 - 更新/新增测试覆盖以上场景。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/content/gm_api/gm_xhr.test.ts | 151 ++++++++++++++++-- src/app/service/content/gm_api/gm_xhr.ts | 65 +++++--- src/pkg/utils/xhr/bg_gm_xhr.test.ts | 19 ++- src/pkg/utils/xhr/bg_gm_xhr.ts | 7 +- src/types/main.d.ts | 2 + src/types/scriptcat.d.ts | 23 ++- src/types/scriptcat.zh-CN.d.ts | 23 ++- 7 files changed, 241 insertions(+), 49 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index c7fd93574..283e952a0 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -28,6 +28,36 @@ function createFakeApi(): { api: GMApi; getMessageHandler: () => ((data: TMessag } describe("GM_xmlhttpRequest 的 upload 事件派发", () => { + it("注册了 upload 回调时,发往后台的 param 应携带 hasUpload: true", async () => { + const { api } = createFakeApi(); + const details = { + url: "https://example.com/upload", + upload: { onprogress: vi.fn() }, + } as unknown as GMTypes.XHRDetails; + + GM_xmlhttpRequest(api, details, false); + await waitTick(); + + const connectMock = api.connect as unknown as ReturnType; + expect(connectMock).toHaveBeenCalledTimes(1); + const [, params] = connectMock.mock.calls[0]; + expect(params[0].hasUpload).toBe(true); + }); + + it("未注册 upload 回调时,发往后台的 param.hasUpload 应为 false", async () => { + const { api } = createFakeApi(); + const details = { + url: "https://example.com/upload", + } as unknown as GMTypes.XHRDetails; + + GM_xmlhttpRequest(api, details, false); + await waitTick(); + + const connectMock = api.connect as unknown as ReturnType; + const [, params] = connectMock.mock.calls[0]; + expect(params[0].hasUpload).toBe(false); + }); + it("收到 onuploadprogress 消息时,应携带 loaded/total/lengthComputable 调用 details.upload.onprogress", async () => { const { api, getMessageHandler } = createFakeApi(); const onprogress = vi.fn(); @@ -74,36 +104,139 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { ["onuploaderror", "onerror"], ["onuploadabort", "onabort"], ["onuploadtimeout", "ontimeout"], - ])("收到 %s 消息时,应调用 details.upload.%s", async (action, uploadHandlerName) => { + ])( + "收到 %s 消息时,应调用 details.upload.%s 并携带 loaded/total/lengthComputable", + async (action, uploadHandlerName) => { + const { api, getMessageHandler } = createFakeApi(); + const handler = vi.fn(); + const details = { + url: "https://example.com/upload", + upload: { [uploadHandlerName]: handler }, + } as unknown as GMTypes.XHRDetails; + + GM_xmlhttpRequest(api, details, false); + await waitTick(); + const messageHandler = getMessageHandler(); + expect(messageHandler).toBeTypeOf("function"); + + messageHandler!({ + action, + data: { + finalUrl: "", + readyState: 1, + status: 0, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: action.slice(2), + ok: false, + contentType: "", + loaded: 42, + total: 100, + lengthComputable: true, + }, + }); + await waitTick(); + + expect(handler).toHaveBeenCalledTimes(1); + const arg = handler.mock.calls[0][0]; + expect(arg.loaded).toBe(42); + expect(arg.total).toBe(100); + expect(arg.lengthComputable).toBe(true); + } + ); + + it("调用返回的 abort() 时,若 upload 阶段尚未完成,应先补发 details.upload.onabort 与 onloadend", async () => { + const { api } = createFakeApi(); + const onUploadAbort = vi.fn(); + const onUploadLoadEnd = vi.fn(); + const onabort = vi.fn(); + const details = { + url: "https://example.com/upload", + onabort, + upload: { + onabort: onUploadAbort, + onloadend: onUploadLoadEnd, + }, + } as unknown as GMTypes.XHRDetails; + + const { abort } = GM_xmlhttpRequest(api, details, false); + await waitTick(); + + abort(); + await waitTick(); + + expect(onUploadAbort).toHaveBeenCalledTimes(1); + expect(onUploadLoadEnd).toHaveBeenCalledTimes(1); + expect(onabort).toHaveBeenCalledTimes(1); + }); + + it("即使未设置 details.onabort,只要注册了 upload 回调,调用 abort() 仍应触发 upload 的 abort/loadend", async () => { + const { api } = createFakeApi(); + const onUploadAbort = vi.fn(); + const onUploadLoadEnd = vi.fn(); + const details = { + url: "https://example.com/upload", + upload: { + onabort: onUploadAbort, + onloadend: onUploadLoadEnd, + }, + } as unknown as GMTypes.XHRDetails; + + const { abort } = GM_xmlhttpRequest(api, details, false); + await waitTick(); + + abort(); + await waitTick(); + + expect(onUploadAbort).toHaveBeenCalledTimes(1); + expect(onUploadLoadEnd).toHaveBeenCalledTimes(1); + }); + + it("upload 阶段已经完成(收到 onuploadloadend)后再调用 abort(),不应重复触发 upload 的 abort/loadend", async () => { const { api, getMessageHandler } = createFakeApi(); - const handler = vi.fn(); + const onUploadAbort = vi.fn(); + const onUploadLoadEnd = vi.fn(); + const onabort = vi.fn(); const details = { url: "https://example.com/upload", - upload: { [uploadHandlerName]: handler }, + onabort, + upload: { + onabort: onUploadAbort, + onloadend: onUploadLoadEnd, + }, } as unknown as GMTypes.XHRDetails; - GM_xmlhttpRequest(api, details, false); + const { abort } = GM_xmlhttpRequest(api, details, false); await waitTick(); const messageHandler = getMessageHandler(); - expect(messageHandler).toBeTypeOf("function"); messageHandler!({ - action, + action: "onuploadloadend", data: { finalUrl: "", - readyState: 1, + readyState: 3, status: 0, statusText: "", responseHeaders: "", useFetch: false, - eventType: action.slice(2), + eventType: "uploadloadend", ok: false, contentType: "", + loaded: 10, + total: 10, + lengthComputable: true, }, }); await waitTick(); + expect(onUploadLoadEnd).toHaveBeenCalledTimes(1); + + abort(); + await waitTick(); - expect(handler).toHaveBeenCalledTimes(1); + expect(onUploadAbort).not.toHaveBeenCalled(); + expect(onUploadLoadEnd).toHaveBeenCalledTimes(1); + expect(onabort).toHaveBeenCalledTimes(1); }); it("不应影响主响应回调(onload 仍正常派发)", async () => { diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index a5ffa5d0d..740d740f1 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -128,6 +128,21 @@ const getMimeType = (contentType: string) => { const docParseTypes = new Set(["application/xhtml+xml", "application/xml", "image/svg+xml", "text/html", "text/xml"]); +// 只有真正注册了至少一个 upload 回调时,后台才会为 xhr.upload 绑定监听器。 +// 原因:为 xhr.upload 注册任何监听器都会令浏览器对跨域请求触发 CORS 预检 (OPTIONS), +// 不应对未使用 upload 功能的脚本造成额外的预检开销/兼容性影响。 +const hasAnyUploadHandler = (upload: GMTypes.XHRUpload | undefined): boolean => + !!( + upload && + (upload.onloadstart || + upload.onprogress || + upload.onload || + upload.onloadend || + upload.onerror || + upload.onabort || + upload.ontimeout) + ); + const retStateFnMap = new WeakMap, RetStateFnRecord>(); interface RetStateFnRecord { @@ -215,6 +230,7 @@ export function GM_xmlhttpRequest( password: details.password, redirect: details.redirect, fetch: details.fetch, + hasUpload: hasAnyUploadHandler(details.upload), }; if (!param.headers) { param.headers = {}; @@ -500,7 +516,19 @@ export function GM_xmlhttpRequest( return makeResponseRet(retParam, addGetters, res.contentType); }; let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; + // upload 事件均为 ProgressEvent (标准语义),统一携带 loaded/total/lengthComputable + const makeUploadCallbackParam = ( + uploadData: TXhrCallBackArg & { lengthComputable?: boolean; loaded?: number; total?: number } + ) => ({ + ...(makeXHRCallbackParam?.(uploadData) ?? {}), + lengthComputable: uploadData.lengthComputable as boolean, + loaded: uploadData.loaded as number, + total: uploadData.total as number, + done: uploadData.loaded, + totalSize: uploadData.total, + }); let loadendCalled = false; + let uploadDone = !hasAnyUploadHandler(details.upload); // 未注册 upload 回调时视为已完成,abort 时无需补发 upload 事件 const doLoadEnd = (data: TXhrCallBackArg) => { if (!loadendCalled) { loadendCalled = true; @@ -521,6 +549,12 @@ export function GM_xmlhttpRequest( doAbort = (data: TXhrCallBackArg) => { if (!reqDone) { errorOccur = "AbortError"; + if (!uploadDone) { + // 对齐原生 XHR abort() 语义:upload 尚未完成时,先补发 upload 的 abort 与 loadend + uploadDone = true; + details.upload?.onabort?.(makeXHRCallbackParam?.(data) ?? {}); + details.upload?.onloadend?.(makeXHRCallbackParam?.(data) ?? {}); + } details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; // 不要进行 refCleanup !要等待最后的 onloadend @@ -703,37 +737,28 @@ export function GM_xmlhttpRequest( case "onabort": doAbort?.(data); break; + // upload 事件均为 ProgressEvent (标准语义),统一携带 loaded/total/lengthComputable case "onuploadloadstart": - details.upload?.onloadstart?.(makeXHRCallbackParam?.(data) ?? {}); + details.upload?.onloadstart?.(makeUploadCallbackParam(data)); break; - case "onuploadprogress": { - if (details.upload?.onprogress) { - const res = { - ...(makeXHRCallbackParam?.(data) ?? {}), - lengthComputable: data.lengthComputable as boolean, - loaded: data.loaded as number, - total: data.total as number, - done: data.loaded, - totalSize: data.total, - }; - details.upload.onprogress?.(res); - } + case "onuploadprogress": + details.upload?.onprogress?.(makeUploadCallbackParam(data)); break; - } case "onuploadload": - details.upload?.onload?.(makeXHRCallbackParam?.(data) ?? {}); + details.upload?.onload?.(makeUploadCallbackParam(data)); break; case "onuploadloadend": - details.upload?.onloadend?.(makeXHRCallbackParam?.(data) ?? {}); + uploadDone = true; + details.upload?.onloadend?.(makeUploadCallbackParam(data)); break; case "onuploaderror": - details.upload?.onerror?.(makeXHRCallbackParam?.(data) ?? {}); + details.upload?.onerror?.(makeUploadCallbackParam(data)); break; case "onuploadabort": - details.upload?.onabort?.(makeXHRCallbackParam?.(data) ?? {}); + details.upload?.onabort?.(makeUploadCallbackParam(data)); break; case "onuploadtimeout": - details.upload?.ontimeout?.(makeXHRCallbackParam?.(data) ?? {}); + details.upload?.ontimeout?.(makeUploadCallbackParam(data)); break; // case "onstream": // controller?.enqueue(new Uint8Array(data)); @@ -757,7 +782,7 @@ export function GM_xmlhttpRequest( connect.disconnect(true); // 断开连结(容忍已断开) connect = null; } - if (doAbort && details.onabort && !reqDone) { + if (doAbort && !reqDone && (details.onabort || hasAnyUploadHandler(details.upload))) { // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/abort // When a request is aborted, its readyState is changed to XMLHttpRequest.UNSENT (0) and the request's status code is set to 0. doAbort?.({ diff --git a/src/pkg/utils/xhr/bg_gm_xhr.test.ts b/src/pkg/utils/xhr/bg_gm_xhr.test.ts index ca7e78083..c2299b69a 100644 --- a/src/pkg/utils/xhr/bg_gm_xhr.test.ts +++ b/src/pkg/utils/xhr/bg_gm_xhr.test.ts @@ -85,8 +85,12 @@ describe("BgGMXhr 的 upload 事件转发", () => { global.XMLHttpRequest = originalXHR; }); - const createXhr = async () => { - const details = { method: "POST", url: "https://example.com/upload" } as unknown as GMSend.XHRDetails; + const createXhr = async (hasUpload = true) => { + const details = { + method: "POST", + url: "https://example.com/upload", + hasUpload, + } as unknown as GMSend.XHRDetails; const bgGmXhr = new BgGMXhr( details, { statusCode: 0, finalUrl: "", responseHeaders: "" }, @@ -101,6 +105,17 @@ describe("BgGMXhr 的 upload 事件转发", () => { return xhr; }; + it("details.hasUpload 为假时,不应为 xhr.upload 绑定任何事件监听器(避免为未使用该功能的请求触发 CORS 预检)", async () => { + const xhr = await createXhr(false); + expect(xhr.upload.onloadstart).toBeUndefined(); + expect(xhr.upload.onprogress).toBeUndefined(); + expect(xhr.upload.onload).toBeUndefined(); + expect(xhr.upload.onloadend).toBeUndefined(); + expect(xhr.upload.onerror).toBeUndefined(); + expect(xhr.upload.onabort).toBeUndefined(); + expect(xhr.upload.ontimeout).toBeUndefined(); + }); + it("原生 XHR 的 upload.onprogress 触发时,应发送携带进度数据的 onuploadprogress 消息", async () => { const xhr = await createXhr(); expect(typeof xhr.upload.onprogress).toBe("function"); diff --git a/src/pkg/utils/xhr/bg_gm_xhr.ts b/src/pkg/utils/xhr/bg_gm_xhr.ts index 083c330f4..dd4da3228 100644 --- a/src/pkg/utils/xhr/bg_gm_xhr.ts +++ b/src/pkg/utils/xhr/bg_gm_xhr.ts @@ -83,7 +83,7 @@ const isProgressEvent = (e: XHREvent): e is ProgressEvent & { type: * | `onreadystatechange(response)` | Called when the request’s `readyState` changes. | * | `ontimeout(response)` | Called if the request times out. | * | `onload(response)` | Called when the request successfully completes. | - * | `upload` | Object probed for `onloadstart`/`onprogress`/`onload`/`onloadend`/`onerror`/`onabort`/`ontimeout`, mirroring `XMLHttpRequestUpload`. Not supported when `fetch: true`. | + * | `upload` | Object probed for `onloadstart`/`onprogress`/`onload`/`onloadend`/`onerror`/`onabort`/`ontimeout`, mirroring `XMLHttpRequestUpload`. Requires the native-XHR strategy; not supported whenever the request runs over `fetch()` (`fetch: true`, `redirect` set, `anonymous`/`mozAnon`, or `responseType: "stream"`). | * * --- * ### Response Object @@ -395,7 +395,10 @@ export class BgGMXhr { baseXHR.onloadend = callback; // upload 阶段事件:仅原生 XMLHttpRequest 提供 xhr.upload,fetch 模式不支持上传进度回调 - if (!(localThis instanceof FetchXHR)) { + // 只在调用方确实注册了 upload 回调 (details.hasUpload) 时才绑定: + // 为 xhr.upload 注册任何监听器都会令浏览器对跨域请求强制触发 CORS 预检 (OPTIONS), + // 未使用 upload 功能的脚本不应承担这一额外开销/兼容性风险。 + if (!(localThis instanceof FetchXHR) && details.hasUpload) { const uploadCallback = (evt: ProgressEvent) => { const xhr = localThis; const eventType = `upload${evt.type}`; diff --git a/src/types/main.d.ts b/src/types/main.d.ts index f0b16b933..5fef84b36 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -79,6 +79,8 @@ declare namespace GMSend { dataType?: "FormData" | "Blob"; redirect?: "follow" | "error" | "manual"; byPassConnect?: boolean; + /** True when the caller registered at least one `upload` event handler; gates whether the background attaches `xhr.upload` listeners (registering any triggers a CORS preflight). */ + hasUpload?: boolean; } interface XHRFormDataFile { diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 4bda98aa8..a86887b8b 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -655,15 +655,18 @@ declare namespace GMTypes { type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; - /** Event handlers for the request's upload phase, mirroring `XMLHttpRequestUpload`. */ + /** + * Event handlers for the request's upload phase, mirroring `XMLHttpRequestUpload`. + * All upload events are progress events per the XHR spec, so every handler receives `XHRProgress`. + */ interface XHRUpload { - onloadstart?: Listener; + onloadstart?: Listener; onprogress?: Listener; - onload?: Listener; - onloadend?: Listener; - onerror?: Listener; - onabort?: Listener; - ontimeout?: Listener; + onload?: Listener; + onloadend?: Listener; + onerror?: Listener; + onabort?: Listener; + ontimeout?: Listener; } interface XHRDetails { @@ -694,7 +697,11 @@ declare namespace GMTypes { redirect?: "follow" | "error" | "manual"; /** Partitioned cookie key for storage partitioning. */ cookiePartition?: Record & { topLevelSite?: string }; - /** Event handlers for the upload phase, mirroring `XMLHttpRequestUpload`. */ + /** + * Event handlers for the upload phase, mirroring `XMLHttpRequestUpload`. + * Requires the native-XHR strategy: not supported whenever the request runs over `fetch()` + * (`fetch: true`, `redirect` set, `anonymous`/`mozAnon`, or `responseType: "stream"`). + */ upload?: XHRUpload; onload?: Listener; diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 51fd0a649..32bfe0c1e 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -661,15 +661,18 @@ declare namespace GMTypes { type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; - /** 上传阶段的事件处理器,对齐 `XMLHttpRequestUpload`。 */ + /** + * 上传阶段的事件处理器,对齐 `XMLHttpRequestUpload`。 + * 依照 XHR 规范,upload 的所有事件均为 progress event,因此各回调统一接收 `XHRProgress`。 + */ interface XHRUpload { - onloadstart?: Listener; + onloadstart?: Listener; onprogress?: Listener; - onload?: Listener; - onloadend?: Listener; - onerror?: Listener; - onabort?: Listener; - ontimeout?: Listener; + onload?: Listener; + onloadend?: Listener; + onerror?: Listener; + onabort?: Listener; + ontimeout?: Listener; } interface XHRDetails { @@ -700,7 +703,11 @@ declare namespace GMTypes { redirect?: "follow" | "error" | "manual"; /** 分区 Cookie 键,用于存储分区。 */ cookiePartition?: Record & { topLevelSite?: string }; - /** 上传阶段的事件处理器,对齐 `XMLHttpRequestUpload`。 */ + /** + * 上传阶段的事件处理器,对齐 `XMLHttpRequestUpload`。 + * 仅原生 XHR 策略支持:当请求改走 fetch()(`fetch: true`、设置了 `redirect`、`anonymous`/`mozAnon`, + * 或 `responseType: "stream"`)时不支持。 + */ upload?: XHRUpload; onload?: Listener; From 7bea70c2d059eb0aea7938ed82e8c6da895e8e45 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:11:08 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E6=AD=A3=20uploa?= =?UTF-8?q?d=20abort=20=E6=97=B6=E5=BA=8F=E7=AB=9E=E6=80=81=E3=80=81?= =?UTF-8?q?=E8=A1=A5=E5=8F=91=E6=95=B0=E6=8D=AE=E5=A5=91=E7=BA=A6=E4=B8=8E?= =?UTF-8?q?=E9=9D=9E=E5=87=BD=E6=95=B0=E5=9B=9E=E8=B0=83=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uploadDone 现在于 load/error/abort/timeout 到达时即置位(对齐规范:upload complete flag 在这些事件派发前已置位),而不仅仅是 loadend;避免在 upload.onload 回调内同步调用 abort() 时被误判为"尚未完成"而重复触发 upload.onabort。 - 新增 uploadLoadEndCalled 去重:upload.onloadend 保证恰好触发一次,无论是 由真实的 onuploadloadend 消息触发,还是由 abort() 兜底补发触发(两者可能 竞态:真实消息可能已在通道断开前送出但因断线而无法再被处理)。 - abort() 补发的 upload.onabort / onloadend 现在统一经由 makeUploadCallbackParam 携带 loaded:0/total:0/lengthComputable:false, 不再是缺少进度字段的裸响应对象,符合已声明的 XHRProgress 类型契约。 - hasAnyUploadHandler 改为只接受函数值:truthy 但非函数的回调(如 { onprogress: true })不再被视为已注册,避免既误触发额外 CORS 预检, 又在事件到达时把非函数值当函数调用而抛出。 - 新增测试覆盖:upload.onload 内同步 abort、补发数据的进度字段、 abort 时的事件顺序 (upload.abort → upload.loadend → 主 abort → 主 loadend)、 以及非函数 upload 回调不启用 upload 监听。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/content/gm_api/gm_xhr.test.ts | 124 ++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 57 +++++--- 2 files changed, 165 insertions(+), 16 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index 283e952a0..b32e33a46 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -239,6 +239,130 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { expect(onabort).toHaveBeenCalledTimes(1); }); + it("在 upload.onload 回调内同步调用 abort() 时,不应误触发 upload.onabort,onloadend 只触发一次", async () => { + const { api, getMessageHandler } = createFakeApi(); + const onUploadAbort = vi.fn(); + const onUploadLoadEnd = vi.fn(); + const requestRef: { current?: ReturnType } = {}; + const details = { + url: "https://example.com/upload", + upload: { + onload: () => requestRef.current?.abort(), + onabort: onUploadAbort, + onloadend: onUploadLoadEnd, + }, + } as unknown as GMTypes.XHRDetails; + + requestRef.current = GM_xmlhttpRequest(api, details, false); + await waitTick(); + const messageHandler = getMessageHandler(); + + // 对齐规范:原生实现会先送 upload load,upload complete flag 随即置位,随后才是 loadend + messageHandler!({ + action: "onuploadload", + data: { + finalUrl: "", + readyState: 1, + status: 0, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "uploadload", + ok: false, + contentType: "", + loaded: 10, + total: 10, + lengthComputable: true, + }, + }); + await waitTick(); + + expect(onUploadAbort).not.toHaveBeenCalled(); + + messageHandler!({ + action: "onuploadloadend", + data: { + finalUrl: "", + readyState: 1, + status: 0, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "uploadloadend", + ok: false, + contentType: "", + loaded: 10, + total: 10, + lengthComputable: true, + }, + }); + await waitTick(); + + expect(onUploadAbort).not.toHaveBeenCalled(); + expect(onUploadLoadEnd).toHaveBeenCalledTimes(1); + }); + + it("同步 abort() 补发的 upload.onabort / onloadend 应携带 loaded:0/total:0/lengthComputable:false", async () => { + const { api } = createFakeApi(); + const onUploadAbort = vi.fn(); + const onUploadLoadEnd = vi.fn(); + const details = { + url: "https://example.com/upload", + upload: { onabort: onUploadAbort, onloadend: onUploadLoadEnd }, + } as unknown as GMTypes.XHRDetails; + + const { abort } = GM_xmlhttpRequest(api, details, false); + await waitTick(); + + abort(); + await waitTick(); + + for (const fn of [onUploadAbort, onUploadLoadEnd]) { + expect(fn).toHaveBeenCalledTimes(1); + const arg = fn.mock.calls[0][0]; + expect(arg.loaded).toBe(0); + expect(arg.total).toBe(0); + expect(arg.lengthComputable).toBe(false); + } + }); + + it("从未完成的 upload 阶段调用 abort() 时,事件应按 upload.abort → upload.loadend → 主 abort → 主 loadend 的顺序触发", async () => { + const { api } = createFakeApi(); + const order: string[] = []; + const details = { + url: "https://example.com/upload", + onabort: () => order.push("main-abort"), + onloadend: () => order.push("main-loadend"), + upload: { + onabort: () => order.push("upload-abort"), + onloadend: () => order.push("upload-loadend"), + }, + } as unknown as GMTypes.XHRDetails; + + const { abort } = GM_xmlhttpRequest(api, details, false); + await waitTick(); + + abort(); + await waitTick(); + + expect(order).toEqual(["upload-abort", "upload-loadend", "main-abort", "main-loadend"]); + }); + + it("upload 回调为非函数真值时,不应视为已注册 upload 回调(不启用 upload 监听,避免额外 CORS 预检与运行时报错)", async () => { + const { api } = createFakeApi(); + const details = { + url: "https://example.com/upload", + upload: { onprogress: true as unknown as GMTypes.Listener }, + } as unknown as GMTypes.XHRDetails; + + GM_xmlhttpRequest(api, details, false); + await waitTick(); + + const connectMock = api.connect as unknown as ReturnType; + const [, params] = connectMock.mock.calls[0]; + expect(params[0].hasUpload).toBe(false); + }); + it("不应影响主响应回调(onload 仍正常派发)", async () => { const { api, getMessageHandler } = createFakeApi(); const onload = vi.fn(); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 740d740f1..395cc5832 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -131,17 +131,19 @@ const docParseTypes = new Set(["application/xhtml+xml", "application/xml", "imag // 只有真正注册了至少一个 upload 回调时,后台才会为 xhr.upload 绑定监听器。 // 原因:为 xhr.upload 注册任何监听器都会令浏览器对跨域请求触发 CORS 预检 (OPTIONS), // 不应对未使用 upload 功能的脚本造成额外的预检开销/兼容性影响。 +// 只接受函数值:truthy 但非函数的值(如 { onprogress: true })既不应启用 upload 监听, +// 也不应在事件到达时被当作函数调用而抛出。 const hasAnyUploadHandler = (upload: GMTypes.XHRUpload | undefined): boolean => - !!( - upload && - (upload.onloadstart || - upload.onprogress || - upload.onload || - upload.onloadend || - upload.onerror || - upload.onabort || - upload.ontimeout) - ); + !!upload && + [ + upload.onloadstart, + upload.onprogress, + upload.onload, + upload.onloadend, + upload.onerror, + upload.onabort, + upload.ontimeout, + ].some((fn) => typeof fn === "function"); const retStateFnMap = new WeakMap, RetStateFnRecord>(); @@ -528,7 +530,19 @@ export function GM_xmlhttpRequest( totalSize: uploadData.total, }); let loadendCalled = false; - let uploadDone = !hasAnyUploadHandler(details.upload); // 未注册 upload 回调时视为已完成,abort 时无需补发 upload 事件 + // 未注册 upload 回调时视为已完成,abort 时无需补发 upload 事件 + let uploadDone = !hasAnyUploadHandler(details.upload); + let uploadLoadEndCalled = false; + // 对齐 XHR 规范:upload 的 load/loadend/error/abort/timeout 到达前,upload complete flag 已置位, + // 所以 onloadend 需要去重——既可能由真实消息触发,也可能由 abort() 的同步补发触发。 + const fireUploadLoadEnd = ( + data: TXhrCallBackArg & { lengthComputable?: boolean; loaded?: number; total?: number } + ) => { + if (!uploadLoadEndCalled) { + uploadLoadEndCalled = true; + details.upload?.onloadend?.(makeUploadCallbackParam(data)); + } + }; const doLoadEnd = (data: TXhrCallBackArg) => { if (!loadendCalled) { loadendCalled = true; @@ -549,12 +563,17 @@ export function GM_xmlhttpRequest( doAbort = (data: TXhrCallBackArg) => { if (!reqDone) { errorOccur = "AbortError"; - if (!uploadDone) { - // 对齐原生 XHR abort() 语义:upload 尚未完成时,先补发 upload 的 abort 与 loadend + const uploadWasDone = uploadDone; + if (!uploadWasDone) { + // 对齐原生 XHR abort() 语义:upload 尚未完成时,先补发 upload 的 abort; + // 此时尚未实际传输(或已中断),loaded/total/lengthComputable 对齐规范取 0/0/false uploadDone = true; - details.upload?.onabort?.(makeXHRCallbackParam?.(data) ?? {}); - details.upload?.onloadend?.(makeXHRCallbackParam?.(data) ?? {}); + details.upload?.onabort?.(makeUploadCallbackParam({ ...data, lengthComputable: false, loaded: 0, total: 0 })); } + // 无论 upload 是自然完成还是被中止,onloadend 都必须触发且只触发一次: + // abort() 会立即断开通道,之后到达的真实 onuploadloadend 消息不再可靠,此处兜底补发; + // fireUploadLoadEnd 内建去重,若真实消息已先行触发过则跳过 + fireUploadLoadEnd(uploadWasDone ? data : { ...data, lengthComputable: false, loaded: 0, total: 0 }); details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; // 不要进行 refCleanup !要等待最后的 onloadend @@ -745,19 +764,25 @@ export function GM_xmlhttpRequest( details.upload?.onprogress?.(makeUploadCallbackParam(data)); break; case "onuploadload": + // 对齐规范:upload complete flag 在 load 派发前已置位,须先于回调设置, + // 否则回调内同步调用 abort() 会误判 upload 尚未完成 + uploadDone = true; details.upload?.onload?.(makeUploadCallbackParam(data)); break; case "onuploadloadend": uploadDone = true; - details.upload?.onloadend?.(makeUploadCallbackParam(data)); + fireUploadLoadEnd(data); break; case "onuploaderror": + uploadDone = true; details.upload?.onerror?.(makeUploadCallbackParam(data)); break; case "onuploadabort": + uploadDone = true; details.upload?.onabort?.(makeUploadCallbackParam(data)); break; case "onuploadtimeout": + uploadDone = true; details.upload?.ontimeout?.(makeUploadCallbackParam(data)); break; // case "onstream": From 3a7388ccf5d2608a9a909e766c7fb8a0c06c63f0 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:33:46 +0900 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E6=AD=A3=20abort?= =?UTF-8?q?()=20=E8=A6=86=E7=9B=96=20upload=20=E9=94=99=E8=AF=AF/=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E6=97=B6=E7=9A=84=E4=B8=BB=E8=AF=B7=E6=B1=82=E7=BB=88?= =?UTF-8?q?=E6=AD=A2=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 对齐规范:request error steps 在派发 upload 的 error/abort/timeout 前, 已将整个请求置为 DONE(原生此时再调用 abort() 不会产生新事件)。新增 suppressSyntheticAbort 标记,在 onuploaderror/onuploadabort/onuploadtimeout 到达时置位;置位后返回的 abort() 变为空操作(不再断开通道、不再合成 AbortError),让真实的主 onerror/ontimeout/onabort 消息能够正常到达并 驱动正确的回调,而不是被本地合成的 onabort 抢先覆盖。 - 新增 lastUploadEventData,记录 upload 成功完成(onuploadload)时的真实 loaded/total/lengthComputable;在 upload.onload 回调内同步调用 abort() 导致真实 onuploadloadend 消息因通道断开而丢失时,兜底补发的 onloadend 使用这份真实数据,而不是 abort() 合成响应对象里缺省的 undefined。 - 新增测试覆盖:upload.onerror / upload.ontimeout 内调用 abort() 不应覆盖 真实的主 onerror/ontimeout;upload.onload 内 abort() 导致 loadend 消息 丢失时,兜底补发的 onloadend 应携带真实的已传输字节数。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/content/gm_api/gm_xhr.test.ts | 110 ++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 49 ++++++-- 2 files changed, 148 insertions(+), 11 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index b32e33a46..52d3071a3 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -302,6 +302,116 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { expect(onUploadLoadEnd).toHaveBeenCalledTimes(1); }); + it("在 upload.onload 回调内同步调用 abort() 且 onloadend 消息因通道断开而丢失时,兜底补发的 onloadend 应携带真实的已传输数据", async () => { + const { api, getMessageHandler } = createFakeApi(); + const onUploadLoadEnd = vi.fn(); + const requestRef: { current?: ReturnType } = {}; + const details = { + url: "https://example.com/upload", + upload: { + onload: () => requestRef.current?.abort(), + onloadend: onUploadLoadEnd, + }, + } as unknown as GMTypes.XHRDetails; + + requestRef.current = GM_xmlhttpRequest(api, details, false); + await waitTick(); + const messageHandler = getMessageHandler(); + + // 真实的 onuploadload 消息(携带真实进度数据);其回调内同步调用 abort(), + // 通道随即断开,真正的 onuploadloadend 消息不会再被处理——由 abort() 兜底补发 + messageHandler!({ + action: "onuploadload", + data: { + finalUrl: "", + readyState: 1, + status: 0, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "uploadload", + ok: false, + contentType: "", + loaded: 512, + total: 1024, + lengthComputable: true, + }, + }); + await waitTick(); + + expect(onUploadLoadEnd).toHaveBeenCalledTimes(1); + const arg = onUploadLoadEnd.mock.calls[0][0]; + expect(arg.loaded).toBe(512); + expect(arg.total).toBe(1024); + expect(arg.lengthComputable).toBe(true); + }); + + it.each([ + ["onuploaderror", "onerror", "onerror"], + ["onuploadtimeout", "ontimeout", "ontimeout"], + ])( + "在 upload.%s 回调内调用 abort() 时应为空操作,让真实的主 %s 消息正常驱动 details.%s", + async (uploadAction, uploadHandlerName, mainHandlerName) => { + const { api, getMessageHandler } = createFakeApi(); + const mainHandler = vi.fn(); + const onMainAbort = vi.fn(); + const requestRef: { current?: ReturnType } = {}; + const details = { + url: "https://example.com/upload", + [mainHandlerName]: mainHandler, + onabort: onMainAbort, + upload: { + [uploadHandlerName]: () => requestRef.current?.abort(), + }, + } as unknown as GMTypes.XHRDetails; + + requestRef.current = GM_xmlhttpRequest(api, details, false); + await waitTick(); + const messageHandler = getMessageHandler(); + + // 后台在真实的主 onerror/ontimeout 之前,先送出配对的 upload 事件 + messageHandler!({ + action: uploadAction, + data: { + finalUrl: "", + readyState: 4, + status: 0, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: uploadAction.slice(2), + ok: false, + contentType: "", + }, + }); + await waitTick(); + + // 回调内调用的 abort() 应为空操作:不应触发合成的主 onabort + expect(onMainAbort).not.toHaveBeenCalled(); + + // 通道未被断开,真实的主消息随后到达时应正常驱动对应回调 + messageHandler!({ + action: mainHandlerName, + data: { + finalUrl: "", + readyState: 4, + status: 0, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: mainHandlerName.slice(2), + ok: false, + contentType: "", + error: mainHandlerName === "onerror" ? "Unknown Error" : undefined, + }, + }); + await waitTick(); + + expect(mainHandler).toHaveBeenCalledTimes(1); + expect(onMainAbort).not.toHaveBeenCalled(); + } + ); + it("同步 abort() 补发的 upload.onabort / onloadend 应携带 loaded:0/total:0/lengthComputable:false", async () => { const { api } = createFakeApi(); const onUploadAbort = vi.fn(); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 395cc5832..5bcac387a 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -189,6 +189,10 @@ export function GM_xmlhttpRequest( isDownload: boolean = false ) { let reqDone = false; + // 一旦 upload 阶段以 error/abort/timeout 结束,整个请求已注定以同样的结果结束(原生实现此时 + // readyState 已为 DONE,再调用 abort() 不会产生新事件)。置位后,返回的 abort() 变为空操作, + // 让真实的主 onerror/ontimeout/onabort 消息自然到达,而不是被本地合成的 AbortError 抢先覆盖。 + let suppressSyntheticAbort = false; if (a.isInvalidContext()) { return { retPromise: requirePromise ? Promise.reject("GM_xmlhttpRequest: Invalid Context") : null, @@ -533,6 +537,15 @@ export function GM_xmlhttpRequest( // 未注册 upload 回调时视为已完成,abort 时无需补发 upload 事件 let uploadDone = !hasAnyUploadHandler(details.upload); let uploadLoadEndCalled = false; + // 记录最后一次真实 upload 进度数据:upload 成功完成后才调用 abort() 时, + // 兜底补发的 onloadend 应携带真实的已传输字节数,而非 abort() 合成响应对象里缺省的 undefined + let lastUploadEventData: + | (TXhrCallBackArg & { + lengthComputable?: boolean; + loaded?: number; + total?: number; + }) + | null = null; // 对齐 XHR 规范:upload 的 load/loadend/error/abort/timeout 到达前,upload complete flag 已置位, // 所以 onloadend 需要去重——既可能由真实消息触发,也可能由 abort() 的同步补发触发。 const fireUploadLoadEnd = ( @@ -569,11 +582,12 @@ export function GM_xmlhttpRequest( // 此时尚未实际传输(或已中断),loaded/total/lengthComputable 对齐规范取 0/0/false uploadDone = true; details.upload?.onabort?.(makeUploadCallbackParam({ ...data, lengthComputable: false, loaded: 0, total: 0 })); + fireUploadLoadEnd({ ...data, lengthComputable: false, loaded: 0, total: 0 }); + } else { + // upload 已经成功完成:兜底补发的 onloadend 使用最后一次真实进度数据, + // 而非 abort() 合成的响应对象(缺少 loaded/total/lengthComputable) + fireUploadLoadEnd(lastUploadEventData ?? data); } - // 无论 upload 是自然完成还是被中止,onloadend 都必须触发且只触发一次: - // abort() 会立即断开通道,之后到达的真实 onuploadloadend 消息不再可靠,此处兜底补发; - // fireUploadLoadEnd 内建去重,若真实消息已先行触发过则跳过 - fireUploadLoadEnd(uploadWasDone ? data : { ...data, lengthComputable: false, loaded: 0, total: 0 }); details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; // 不要进行 refCleanup !要等待最后的 onloadend @@ -767,6 +781,9 @@ export function GM_xmlhttpRequest( // 对齐规范:upload complete flag 在 load 派发前已置位,须先于回调设置, // 否则回调内同步调用 abort() 会误判 upload 尚未完成 uploadDone = true; + // 记录真实进度数据:若回调内同步调用 abort(),兜底补发的 onloadend 需要这份数据, + // 而不是 abort() 合成响应对象里缺省的 undefined + lastUploadEventData = data; details.upload?.onload?.(makeUploadCallbackParam(data)); break; case "onuploadloadend": @@ -774,16 +791,21 @@ export function GM_xmlhttpRequest( fireUploadLoadEnd(data); break; case "onuploaderror": - uploadDone = true; - details.upload?.onerror?.(makeUploadCallbackParam(data)); - break; case "onuploadabort": - uploadDone = true; - details.upload?.onabort?.(makeUploadCallbackParam(data)); - break; case "onuploadtimeout": + // 对齐规范:request error steps 在派发 upload 的 error/abort/timeout 前, + // 已将整个请求置为 DONE——此时再调用 abort() 不会产生新事件(原生行为即为空操作)。 + // 让返回的 abort() 变为空操作,使真实的主 onerror/ontimeout/onabort 消息能够到达, + // 而不是被这里同步调用 abort() 时合成的 AbortError 抢先覆盖。 uploadDone = true; - details.upload?.ontimeout?.(makeUploadCallbackParam(data)); + suppressSyntheticAbort = true; + if (msgData.action === "onuploaderror") { + details.upload?.onerror?.(makeUploadCallbackParam(data)); + } else if (msgData.action === "onuploadabort") { + details.upload?.onabort?.(makeUploadCallbackParam(data)); + } else { + details.upload?.ontimeout?.(makeUploadCallbackParam(data)); + } break; // case "onstream": // controller?.enqueue(new Uint8Array(data)); @@ -803,6 +825,11 @@ export function GM_xmlhttpRequest( return { retPromise, abort: () => { + if (suppressSyntheticAbort) { + // upload 阶段已以 error/abort/timeout 结束,整个请求已注定失败:保持通道开启, + // 让真实的主 onerror/ontimeout/onabort 消息自然到达并驱动正确的回调 + return; + } if (connect) { connect.disconnect(true); // 断开连结(容忍已断开) connect = null; From 700f03c5301023deb6d11bfed1f684e5280374e8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:57:15 +0900 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E6=AD=A3=20abort?= =?UTF-8?q?()=20=E5=9C=A8=E6=97=A0=E7=9C=9F=E5=AE=9E=20upload=20=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E6=97=B6=E4=BB=8D=E5=90=88=E6=88=90=20upload=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 对齐 XHR 规范:GET/HEAD 请求体会被忽略;无请求体时 upload complete flag 在发送前已置位;fetch 传输不绑定 xhr.upload——以上情况均不会产生真实的 原生 upload 阶段。此前 uploadDone 仅依据"是否注册了 upload 回调"初始化, 与是否存在真实 upload 阶段无关,导致这些场景下调用 abort() 仍会错误地 合成 upload.onabort / onloadend。 - 新增 canHaveUploadLifecycle:在请求体编码完成、确认 method/body/transport 后计算(method 非 GET/HEAD、有实际请求体、且不会走 fetch 传输、且注册了 回调),用其替代 hasAnyUploadHandler(details.upload) 来初始化 uploadDone, 并据此决定发往后台的 hasUpload 标记——避免此前对 GET/无请求体的原生 XHR 请求也绑定 xhr.upload 监听器(本可避免的 CORS 预检开销)。 - doAbort 内的 upload 合成逻辑整体收敛到 canHaveUploadLifecycle 判断之下, 避免"upload 从未开始"与"upload 已成功完成"两种 uploadDone=true 的语义 被混淆(前者不应触发任何 upload.* 回调,后者才应携带真实数据补发 loadend)。 - 更新/新增测试:GET、HEAD、POST 无 data、fetch/redirect/anonymous/stream 等场景下 hasUpload 应为 false 且 abort() 不触发 upload 事件;POST 带 请求体的既有测试同步补上 method/data 以符合真实场景。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/content/gm_api/gm_xhr.test.ts | 80 ++++++++++++++++++- src/app/service/content/gm_api/gm_xhr.ts | 54 +++++++++---- 2 files changed, 116 insertions(+), 18 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index 52d3071a3..76c4a234b 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -28,10 +28,12 @@ function createFakeApi(): { api: GMApi; getMessageHandler: () => ((data: TMessag } describe("GM_xmlhttpRequest 的 upload 事件派发", () => { - it("注册了 upload 回调时,发往后台的 param 应携带 hasUpload: true", async () => { + it("POST 且带请求体、注册了 upload 回调时,发往后台的 param 应携带 hasUpload: true", async () => { const { api } = createFakeApi(); const details = { url: "https://example.com/upload", + method: "POST", + data: "payload", upload: { onprogress: vi.fn() }, } as unknown as GMTypes.XHRDetails; @@ -44,10 +46,36 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { expect(params[0].hasUpload).toBe(true); }); - it("未注册 upload 回调时,发往后台的 param.hasUpload 应为 false", async () => { + it("未注册 upload 回调时,即使 POST 带请求体,发往后台的 param.hasUpload 也应为 false", async () => { const { api } = createFakeApi(); const details = { url: "https://example.com/upload", + method: "POST", + data: "payload", + } as unknown as GMTypes.XHRDetails; + + GM_xmlhttpRequest(api, details, false); + await waitTick(); + + const connectMock = api.connect as unknown as ReturnType; + const [, params] = connectMock.mock.calls[0]; + expect(params[0].hasUpload).toBe(false); + }); + + it.each([ + ["GET 请求(无请求体,即便注册了 upload 回调)", { method: "GET" }], + ["HEAD 请求(无请求体,即便注册了 upload 回调)", { method: "HEAD" }], + ["POST 但未提供 data(无请求体)", { method: "POST" }], + ["fetch: true(改走 fetch 传输,不支持 upload)", { method: "POST", data: "payload", fetch: true }], + ["设置了 redirect(改走 fetch 传输)", { method: "POST", data: "payload", redirect: "follow" }], + ["anonymous: true(改走 fetch 传输)", { method: "POST", data: "payload", anonymous: true }], + ["responseType: stream(改走 fetch 传输)", { method: "POST", data: "payload", responseType: "stream" }], + ])("%s:即使注册了 upload 回调,param.hasUpload 也应为 false(不会产生真实 upload 阶段)", async (_label, extra) => { + const { api } = createFakeApi(); + const details = { + url: "https://example.com/upload", + ...extra, + upload: { onabort: vi.fn(), onloadend: vi.fn() }, } as unknown as GMTypes.XHRDetails; GM_xmlhttpRequest(api, details, false); @@ -146,13 +174,15 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { } ); - it("调用返回的 abort() 时,若 upload 阶段尚未完成,应先补发 details.upload.onabort 与 onloadend", async () => { + it("调用返回的 abort() 时,若 upload 阶段尚未完成(POST 带请求体),应先补发 details.upload.onabort 与 onloadend", async () => { const { api } = createFakeApi(); const onUploadAbort = vi.fn(); const onUploadLoadEnd = vi.fn(); const onabort = vi.fn(); const details = { url: "https://example.com/upload", + method: "POST", + data: "payload", onabort, upload: { onabort: onUploadAbort, @@ -171,12 +201,42 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { expect(onabort).toHaveBeenCalledTimes(1); }); - it("即使未设置 details.onabort,只要注册了 upload 回调,调用 abort() 仍应触发 upload 的 abort/loadend", async () => { + it.each([ + ["GET 请求", { method: "GET" }], + ["HEAD 请求", { method: "HEAD" }], + ["POST 但未提供 data", { method: "POST" }], + ["fetch: true", { method: "POST", data: "payload", fetch: true }], + ])( + "%s:即使注册了 upload 回调,调用 abort() 也不应触发 upload 的 abort/loadend(不存在真实 upload 阶段)", + async (_label, extra) => { + const { api } = createFakeApi(); + const onUploadAbort = vi.fn(); + const onUploadLoadEnd = vi.fn(); + const details = { + url: "https://example.com/upload", + ...extra, + upload: { onabort: onUploadAbort, onloadend: onUploadLoadEnd }, + } as unknown as GMTypes.XHRDetails; + + const { abort } = GM_xmlhttpRequest(api, details, false); + await waitTick(); + + abort(); + await waitTick(); + + expect(onUploadAbort).not.toHaveBeenCalled(); + expect(onUploadLoadEnd).not.toHaveBeenCalled(); + } + ); + + it("即使未设置 details.onabort,只要注册了 upload 回调且存在真实 upload 阶段,调用 abort() 仍应触发 upload 的 abort/loadend", async () => { const { api } = createFakeApi(); const onUploadAbort = vi.fn(); const onUploadLoadEnd = vi.fn(); const details = { url: "https://example.com/upload", + method: "POST", + data: "payload", upload: { onabort: onUploadAbort, onloadend: onUploadLoadEnd, @@ -200,6 +260,8 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { const onabort = vi.fn(); const details = { url: "https://example.com/upload", + method: "POST", + data: "payload", onabort, upload: { onabort: onUploadAbort, @@ -246,6 +308,8 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { const requestRef: { current?: ReturnType } = {}; const details = { url: "https://example.com/upload", + method: "POST", + data: "payload", upload: { onload: () => requestRef.current?.abort(), onabort: onUploadAbort, @@ -308,6 +372,8 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { const requestRef: { current?: ReturnType } = {}; const details = { url: "https://example.com/upload", + method: "POST", + data: "payload", upload: { onload: () => requestRef.current?.abort(), onloadend: onUploadLoadEnd, @@ -358,6 +424,8 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { const requestRef: { current?: ReturnType } = {}; const details = { url: "https://example.com/upload", + method: "POST", + data: "payload", [mainHandlerName]: mainHandler, onabort: onMainAbort, upload: { @@ -418,6 +486,8 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { const onUploadLoadEnd = vi.fn(); const details = { url: "https://example.com/upload", + method: "POST", + data: "payload", upload: { onabort: onUploadAbort, onloadend: onUploadLoadEnd }, } as unknown as GMTypes.XHRDetails; @@ -441,6 +511,8 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { const order: string[] = []; const details = { url: "https://example.com/upload", + method: "POST", + data: "payload", onabort: () => order.push("main-abort"), onloadend: () => order.push("main-loadend"), upload: { diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 5bcac387a..cf80d3ea4 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -145,6 +145,12 @@ const hasAnyUploadHandler = (upload: GMTypes.XHRUpload | undefined): boolean => upload.ontimeout, ].some((fn) => typeof fn === "function"); +// 与 bg_gm_xhr.ts 的 useFetch 判断保持一致:这些条件下后台会改走 fetch 传输,不会绑定 xhr.upload +const isFetchForcedTransport = (details: GMTypes.XHRDetails): boolean => { + const anonymous = details.anonymous ?? details.mozAnon ?? false; + return !!(details.fetch || details.redirect || anonymous || details.responseType === "stream"); +}; + const retStateFnMap = new WeakMap, RetStateFnRecord>(); interface RetStateFnRecord { @@ -236,7 +242,7 @@ export function GM_xmlhttpRequest( password: details.password, redirect: details.redirect, fetch: details.fetch, - hasUpload: hasAnyUploadHandler(details.upload), + // hasUpload 需等待请求体编码完成、确认方法/请求体真的会产生 upload 阶段后才能计算,见下方 IIFE }; if (!param.headers) { param.headers = {}; @@ -253,6 +259,19 @@ export function GM_xmlhttpRequest( param.url = u.href; param.data = dataResolved; + // 对齐 XHR 规范:GET/HEAD 的请求体会被忽略,无请求体时 upload complete flag 在发送前已置位, + // 均不会产生 upload 阶段。fetch 传输也不绑定 xhr.upload(见 bg_gm_xhr.ts)。 + // 只有真正会产生原生 upload 生命周期时,才告知后台绑定监听器、才在本地为 abort() 补发 upload 事件。 + const uploadMethod = details.method || "GET"; + const hasRequestBody = dataResolved.type !== "undefined" && dataResolved.type !== "null"; + const canHaveUploadLifecycle = + !isFetchForcedTransport(details) && + uploadMethod !== "GET" && + uploadMethod !== "HEAD" && + hasRequestBody && + hasAnyUploadHandler(details.upload); + param.hasUpload = canHaveUploadLifecycle; + // 处理返回数据 const isStreamResponse = responseTypeOriginal === "stream"; let readerStream: ReadableStream> | undefined; @@ -534,8 +553,9 @@ export function GM_xmlhttpRequest( totalSize: uploadData.total, }); let loadendCalled = false; - // 未注册 upload 回调时视为已完成,abort 时无需补发 upload 事件 - let uploadDone = !hasAnyUploadHandler(details.upload); + // 请求不会产生真实 upload 阶段时(GET/HEAD、无请求体、fetch 传输、或未注册回调)视为已完成, + // abort 时无需补发 upload 事件 + let uploadDone = !canHaveUploadLifecycle; let uploadLoadEndCalled = false; // 记录最后一次真实 upload 进度数据:upload 成功完成后才调用 abort() 时, // 兜底补发的 onloadend 应携带真实的已传输字节数,而非 abort() 合成响应对象里缺省的 undefined @@ -576,17 +596,23 @@ export function GM_xmlhttpRequest( doAbort = (data: TXhrCallBackArg) => { if (!reqDone) { errorOccur = "AbortError"; - const uploadWasDone = uploadDone; - if (!uploadWasDone) { - // 对齐原生 XHR abort() 语义:upload 尚未完成时,先补发 upload 的 abort; - // 此时尚未实际传输(或已中断),loaded/total/lengthComputable 对齐规范取 0/0/false - uploadDone = true; - details.upload?.onabort?.(makeUploadCallbackParam({ ...data, lengthComputable: false, loaded: 0, total: 0 })); - fireUploadLoadEnd({ ...data, lengthComputable: false, loaded: 0, total: 0 }); - } else { - // upload 已经成功完成:兜底补发的 onloadend 使用最后一次真实进度数据, - // 而非 abort() 合成的响应对象(缺少 loaded/total/lengthComputable) - fireUploadLoadEnd(lastUploadEventData ?? data); + // canHaveUploadLifecycle 为 false 时(GET/HEAD、无请求体、或走 fetch 传输)根本不存在原生 + // upload 阶段,不应触发任何 upload.* 回调;只有真正可能产生 upload 阶段的请求才需要处理 + if (canHaveUploadLifecycle) { + const uploadWasDone = uploadDone; + if (!uploadWasDone) { + // 对齐原生 XHR abort() 语义:upload 尚未完成时,先补发 upload 的 abort; + // 此时尚未实际传输(或已中断),loaded/total/lengthComputable 对齐规范取 0/0/false + uploadDone = true; + details.upload?.onabort?.( + makeUploadCallbackParam({ ...data, lengthComputable: false, loaded: 0, total: 0 }) + ); + fireUploadLoadEnd({ ...data, lengthComputable: false, loaded: 0, total: 0 }); + } else { + // upload 已经成功完成:兜底补发的 onloadend 使用最后一次真实进度数据, + // 而非 abort() 合成的响应对象(缺少 loaded/total/lengthComputable) + fireUploadLoadEnd(lastUploadEventData ?? data); + } } details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; From bdab3d75d47b6101d576fb16f3d2fed04f3ff4cb Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:11:00 +0900 Subject: [PATCH 06/11] Update gm_xhr.ts --- src/app/service/content/gm_api/gm_xhr.ts | 57 +++++++++++------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index cf80d3ea4..4d79628f0 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -199,6 +199,8 @@ export function GM_xmlhttpRequest( // readyState 已为 DONE,再调用 abort() 不会产生新事件)。置位后,返回的 abort() 变为空操作, // 让真实的主 onerror/ontimeout/onabort 消息自然到达,而不是被本地合成的 AbortError 抢先覆盖。 let suppressSyntheticAbort = false; + // details.upload 在请求生命周期内不会变化,只需计算一次,供下方 canHaveUploadLifecycle 与 abort() 共用 + const uploadHasHandlers = hasAnyUploadHandler(details.upload); if (a.isInvalidContext()) { return { retPromise: requirePromise ? Promise.reject("GM_xmlhttpRequest: Invalid Context") : null, @@ -269,7 +271,7 @@ export function GM_xmlhttpRequest( uploadMethod !== "GET" && uploadMethod !== "HEAD" && hasRequestBody && - hasAnyUploadHandler(details.upload); + uploadHasHandlers; param.hasUpload = canHaveUploadLifecycle; // 处理返回数据 @@ -541,21 +543,22 @@ export function GM_xmlhttpRequest( return makeResponseRet(retParam, addGetters, res.contentType); }; let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; - // upload 事件均为 ProgressEvent (标准语义),统一携带 loaded/total/lengthComputable - const makeUploadCallbackParam = ( - uploadData: TXhrCallBackArg & { lengthComputable?: boolean; loaded?: number; total?: number } + // progress 事件均为 ProgressEvent (标准语义),统一携带 loaded/total/lengthComputable; + // 主请求 onprogress 与 upload.* 事件共用同一套字段拼装逻辑 + const makeProgressCallbackParam = ( + progressData: TXhrCallBackArg & { lengthComputable?: boolean; loaded?: number; total?: number } ) => ({ - ...(makeXHRCallbackParam?.(uploadData) ?? {}), - lengthComputable: uploadData.lengthComputable as boolean, - loaded: uploadData.loaded as number, - total: uploadData.total as number, - done: uploadData.loaded, - totalSize: uploadData.total, + ...(makeXHRCallbackParam?.(progressData) ?? {}), + lengthComputable: progressData.lengthComputable as boolean, + loaded: progressData.loaded as number, + total: progressData.total as number, + done: progressData.loaded, + totalSize: progressData.total, }); let loadendCalled = false; - // 请求不会产生真实 upload 阶段时(GET/HEAD、无请求体、fetch 传输、或未注册回调)视为已完成, - // abort 时无需补发 upload 事件 - let uploadDone = !canHaveUploadLifecycle; + // uploadDone 只在 canHaveUploadLifecycle 为 true 时才会被读取(见下方 doAbort), + // 请求不会产生真实 upload 阶段时该标志天然不参与判断 + let uploadDone = false; let uploadLoadEndCalled = false; // 记录最后一次真实 upload 进度数据:upload 成功完成后才调用 abort() 时, // 兜底补发的 onloadend 应携带真实的已传输字节数,而非 abort() 合成响应对象里缺省的 undefined @@ -573,7 +576,7 @@ export function GM_xmlhttpRequest( ) => { if (!uploadLoadEndCalled) { uploadLoadEndCalled = true; - details.upload?.onloadend?.(makeUploadCallbackParam(data)); + details.upload?.onloadend?.(makeProgressCallbackParam(data)); } }; const doLoadEnd = (data: TXhrCallBackArg) => { @@ -605,7 +608,7 @@ export function GM_xmlhttpRequest( // 此时尚未实际传输(或已中断),loaded/total/lengthComputable 对齐规范取 0/0/false uploadDone = true; details.upload?.onabort?.( - makeUploadCallbackParam({ ...data, lengthComputable: false, loaded: 0, total: 0 }) + makeProgressCallbackParam({ ...data, lengthComputable: false, loaded: 0, total: 0 }) ); fireUploadLoadEnd({ ...data, lengthComputable: false, loaded: 0, total: 0 }); } else { @@ -750,15 +753,7 @@ export function GM_xmlhttpRequest( break; case "onprogress": { if (details.onprogress) { - const res = { - ...(makeXHRCallbackParam?.(data) ?? {}), - lengthComputable: data.lengthComputable as boolean, - loaded: data.loaded as number, - total: data.total as number, - done: data.loaded, - totalSize: data.total, - }; - details.onprogress?.(res); + details.onprogress?.(makeProgressCallbackParam(data)); } break; } @@ -798,10 +793,10 @@ export function GM_xmlhttpRequest( break; // upload 事件均为 ProgressEvent (标准语义),统一携带 loaded/total/lengthComputable case "onuploadloadstart": - details.upload?.onloadstart?.(makeUploadCallbackParam(data)); + details.upload?.onloadstart?.(makeProgressCallbackParam(data)); break; case "onuploadprogress": - details.upload?.onprogress?.(makeUploadCallbackParam(data)); + details.upload?.onprogress?.(makeProgressCallbackParam(data)); break; case "onuploadload": // 对齐规范:upload complete flag 在 load 派发前已置位,须先于回调设置, @@ -810,7 +805,7 @@ export function GM_xmlhttpRequest( // 记录真实进度数据:若回调内同步调用 abort(),兜底补发的 onloadend 需要这份数据, // 而不是 abort() 合成响应对象里缺省的 undefined lastUploadEventData = data; - details.upload?.onload?.(makeUploadCallbackParam(data)); + details.upload?.onload?.(makeProgressCallbackParam(data)); break; case "onuploadloadend": uploadDone = true; @@ -826,11 +821,11 @@ export function GM_xmlhttpRequest( uploadDone = true; suppressSyntheticAbort = true; if (msgData.action === "onuploaderror") { - details.upload?.onerror?.(makeUploadCallbackParam(data)); + details.upload?.onerror?.(makeProgressCallbackParam(data)); } else if (msgData.action === "onuploadabort") { - details.upload?.onabort?.(makeUploadCallbackParam(data)); + details.upload?.onabort?.(makeProgressCallbackParam(data)); } else { - details.upload?.ontimeout?.(makeUploadCallbackParam(data)); + details.upload?.ontimeout?.(makeProgressCallbackParam(data)); } break; // case "onstream": @@ -860,7 +855,7 @@ export function GM_xmlhttpRequest( connect.disconnect(true); // 断开连结(容忍已断开) connect = null; } - if (doAbort && !reqDone && (details.onabort || hasAnyUploadHandler(details.upload))) { + if (doAbort && !reqDone && (details.onabort || uploadHasHandlers)) { // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/abort // When a request is aborted, its readyState is changed to XMLHttpRequest.UNSENT (0) and the request's status code is set to 0. doAbort?.({ From b55117769bac628a6ce6d26c4bedfb6f7123e28e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:27:08 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=E2=9C=85=20=E6=94=BE=E5=AE=BD=20gm-api?= =?UTF-8?q?=20e2e=20context=20fixture=20=E7=9A=84=20SW=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E8=B6=85=E6=97=B6=E8=87=B3=2060s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 下 workers:2 并行启动多个 Chrome 实例造成资源争抢,30s 偶发不足以等到扩展 service worker 就绪,导致 fixture 阶段超时误报(与 gm_xhr.ts 上传事件改动无关, 该测试不涉及任何 GM_xmlhttpRequest 调用)。 Co-Authored-By: Claude Sonnet 5 --- e2e/gm-api.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 56e16b974..acc0afd3e 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -35,7 +35,8 @@ const test = base.extend<{ args: ["--headless=new", ...chromeArgs], }); let [bg] = ctx1.serviceWorkers(); - if (!bg) bg = await ctx1.waitForEvent("serviceworker", { timeout: 30_000 }); + // CI 下并行 worker 抢占 CPU 会拖慢扩展 service worker 启动,30s 偶发不足,放宽到 60s 降低误报 + if (!bg) bg = await ctx1.waitForEvent("serviceworker", { timeout: 60_000 }); const extensionId = bg.url().split("/")[2]; const extPage = await ctx1.newPage(); await extPage.goto("chrome://extensions/"); @@ -56,7 +57,8 @@ const test = base.extend<{ args: ["--headless=new", ...chromeArgs], }); const [sw] = context.serviceWorkers(); - if (!sw) await context.waitForEvent("serviceworker", { timeout: 30_000 }); + // 同上:与 Phase 1 保持一致,放宽到 60s + if (!sw) await context.waitForEvent("serviceworker", { timeout: 60_000 }); await use(context); await context.close(); fs.rmSync(userDataDir, { recursive: true, force: true }); From fecf1003ae91cf81d86d5c739e2b601a0f421503 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:33:05 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20gm-api=20e2e:=20?= =?UTF-8?q?=E6=9D=83=E9=99=90=E9=85=8D=E7=BD=AE=20Phase=201=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20worker=20=E7=BA=A7=20fixture=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=AF=8F=E6=B5=8B=E8=AF=95=E9=87=8D=E5=A4=8D=E5=90=AF?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前每个 test 都要完整走两次 launchPersistentContext(含 chrome://extensions 导航 + developerPrivate 权限调用),CI 下多个 worker 并行时大量并发 Chrome 启动互相抢占 CPU,是 service worker 启动超时误报的根因。 参照 e2e/agent-fixtures.ts 已验证过的模式:新增 worker 级 gmApiProfileDir fixture,只在每个 worker 里做一次 Phase 1 权限配置,每个 test 改为拷贝该 profile 目录后只做一次轻量 Phase 2 启动。本地验证:整个 spec 文件 7 个测试 2 workers 下 16.8s 全部通过。 Co-Authored-By: Claude Sonnet 5 --- e2e/gm-api.spec.ts | 88 +++++++++++++++++++++++++++------------------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index acc0afd3e..6831a124f 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -9,55 +9,71 @@ import { autoApprovePermissions, installScriptByCode } from "./utils"; const MOCK_CONNECT_HOST = "127.0.0.1"; const CSP_TARGET_HOST = "content-security-policy.test"; +const pathToExtension = path.resolve(__dirname, "../dist/ext"); +const chromeArgs = [ + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + `--host-resolver-rules=MAP ${CSP_TARGET_HOST} ${MOCK_CONNECT_HOST},EXCLUDE localhost`, +]; + type GMApiMockServer = { origin: string; cspOrigin: string; close: () => Promise; }; -const test = base.extend<{ - context: BrowserContext; - extensionId: string; -}>({ - // eslint-disable-next-line no-empty-pattern - context: async ({}, use) => { - const pathToExtension = path.resolve(__dirname, "../dist/ext"); - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-")); - const chromeArgs = [ - `--disable-extensions-except=${pathToExtension}`, - `--load-extension=${pathToExtension}`, - `--host-resolver-rules=MAP ${CSP_TARGET_HOST} ${MOCK_CONNECT_HOST},EXCLUDE localhost`, - ]; - - // Phase 1: Enable user scripts permission - const ctx1 = await chromium.launchPersistentContext(userDataDir, { - headless: false, - args: ["--headless=new", ...chromeArgs], - }); - let [bg] = ctx1.serviceWorkers(); - // CI 下并行 worker 抢占 CPU 会拖慢扩展 service worker 启动,30s 偶发不足,放宽到 60s 降低误报 - if (!bg) bg = await ctx1.waitForEvent("serviceworker", { timeout: 60_000 }); - const extensionId = bg.url().split("/")[2]; - const extPage = await ctx1.newPage(); - await extPage.goto("chrome://extensions/"); - await extPage.waitForLoadState("domcontentloaded"); - await extPage.waitForFunction(() => !!(chrome as any).developerPrivate, { timeout: 10_000 }); - await extPage.evaluate(async (id) => { - await (chrome as any).developerPrivate.updateExtensionConfiguration({ - extensionId: id, - userScriptsAccess: true, +const test = base.extend< + { + context: BrowserContext; + extensionId: string; + }, + { gmApiProfileDir: string } +>({ + // Worker 级 fixture:启用 user scripts 权限的 Phase 1(含 chrome://extensions 导航 + + // developerPrivate 调用)每个 worker 只做一次,而不是每个 test 都重新做一遍。 + // 之前每个 test 都要完整走两次 launchPersistentContext,CI 下 workers 并行时 + // 大量并发 Chrome 启动会互相抢占 CPU,把扩展 service worker 的启动拖到超过 30s 超时。 + gmApiProfileDir: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use) => { + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-profile-")); + + const ctx1 = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: ["--headless=new", ...chromeArgs], }); - }, extensionId); - await extPage.close(); - await ctx1.close(); + let [bg] = ctx1.serviceWorkers(); + if (!bg) bg = await ctx1.waitForEvent("serviceworker", { timeout: 60_000 }); + const extensionId = bg.url().split("/")[2]; + const extPage = await ctx1.newPage(); + await extPage.goto("chrome://extensions/"); + await extPage.waitForLoadState("domcontentloaded"); + await extPage.waitForFunction(() => !!(chrome as any).developerPrivate, { timeout: 10_000 }); + await extPage.evaluate(async (id) => { + await (chrome as any).developerPrivate.updateExtensionConfiguration({ + extensionId: id, + userScriptsAccess: true, + }); + }, extensionId); + await extPage.close(); + await ctx1.close(); + + await use(userDataDir); + fs.rmSync(userDataDir, { recursive: true, force: true }); + }, + { scope: "worker" }, + ], + context: async ({ gmApiProfileDir }, use) => { + // 每个测试使用从预配置 profile 拷贝出的独立目录,避免脚本/storage 状态泄漏到后续测试, + // 同时跳过每个测试都重新做一次的 Phase 1 权限配置。 + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-test-")); + fs.cpSync(gmApiProfileDir, userDataDir, { recursive: true }); - // Phase 2: Relaunch with user scripts enabled const context = await chromium.launchPersistentContext(userDataDir, { headless: false, args: ["--headless=new", ...chromeArgs], }); const [sw] = context.serviceWorkers(); - // 同上:与 Phase 1 保持一致,放宽到 60s if (!sw) await context.waitForEvent("serviceworker", { timeout: 60_000 }); await use(context); await context.close(); From 730652f4444d494e84256d72a86eca16e063a12c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:13:59 +0900 Subject: [PATCH 09/11] Update gm-api.spec.ts --- e2e/gm-api.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 6831a124f..21bc5407e 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -43,7 +43,7 @@ const test = base.extend< args: ["--headless=new", ...chromeArgs], }); let [bg] = ctx1.serviceWorkers(); - if (!bg) bg = await ctx1.waitForEvent("serviceworker", { timeout: 60_000 }); + if (!bg) bg = await ctx1.waitForEvent("serviceworker", { timeout: 30_000 }); const extensionId = bg.url().split("/")[2]; const extPage = await ctx1.newPage(); await extPage.goto("chrome://extensions/"); @@ -74,7 +74,7 @@ const test = base.extend< args: ["--headless=new", ...chromeArgs], }); const [sw] = context.serviceWorkers(); - if (!sw) await context.waitForEvent("serviceworker", { timeout: 60_000 }); + if (!sw) await context.waitForEvent("serviceworker", { timeout: 30_000 }); await use(context); await context.close(); fs.rmSync(userDataDir, { recursive: true, force: true }); From 2c105be2d4b3b1d1b91ac552aeee21232be086b8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:32:18 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=90=9B=20preserve=20upload=20progre?= =?UTF-8?q?ss=20when=20aborting=20XHR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 50 +++++++++++++++++++ src/app/service/content/gm_api/gm_xhr.ts | 16 ++++-- src/template/scriptcat.d.tpl | 11 ++++ 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index 76c4a234b..71683c1a7 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -506,6 +506,56 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { } }); + it("收到 upload 进度后调用 abort(),补发的 upload.onabort / onloadend 应携带最后一次进度数据", async () => { + const { api, getMessageHandler } = createFakeApi(); + const onUploadAbort = vi.fn(); + const onUploadLoadEnd = vi.fn(); + const details = { + url: "https://example.com/upload", + method: "POST", + data: "payload", + upload: { + onprogress: vi.fn(), + onabort: onUploadAbort, + onloadend: onUploadLoadEnd, + }, + } as unknown as GMTypes.XHRDetails; + + const { abort } = GM_xmlhttpRequest(api, details, false); + await waitTick(); + const messageHandler = getMessageHandler(); + + messageHandler!({ + action: "onuploadprogress", + data: { + finalUrl: "", + readyState: 1, + status: 0, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "uploadprogress", + ok: false, + contentType: "", + loaded: 5 * 1024 * 1024, + total: 10 * 1024 * 1024, + lengthComputable: true, + }, + }); + await waitTick(); + + abort(); + await waitTick(); + + for (const fn of [onUploadAbort, onUploadLoadEnd]) { + expect(fn).toHaveBeenCalledTimes(1); + const arg = fn.mock.calls[0][0]; + expect(arg.loaded).toBe(5 * 1024 * 1024); + expect(arg.total).toBe(10 * 1024 * 1024); + expect(arg.lengthComputable).toBe(true); + } + }); + it("从未完成的 upload 阶段调用 abort() 时,事件应按 upload.abort → upload.loadend → 主 abort → 主 loadend 的顺序触发", async () => { const { api } = createFakeApi(); const order: string[] = []; diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index 4d79628f0..c2486aa3f 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -605,12 +605,16 @@ export function GM_xmlhttpRequest( const uploadWasDone = uploadDone; if (!uploadWasDone) { // 对齐原生 XHR abort() 语义:upload 尚未完成时,先补发 upload 的 abort; - // 此时尚未实际传输(或已中断),loaded/total/lengthComputable 对齐规范取 0/0/false + // 若已收到进度事件,则沿用最后一次真实进度数据 uploadDone = true; - details.upload?.onabort?.( - makeProgressCallbackParam({ ...data, lengthComputable: false, loaded: 0, total: 0 }) - ); - fireUploadLoadEnd({ ...data, lengthComputable: false, loaded: 0, total: 0 }); + const uploadEventData = lastUploadEventData ?? { + ...data, + lengthComputable: false, + loaded: 0, + total: 0, + }; + details.upload?.onabort?.(makeProgressCallbackParam(uploadEventData)); + fireUploadLoadEnd(uploadEventData); } else { // upload 已经成功完成:兜底补发的 onloadend 使用最后一次真实进度数据, // 而非 abort() 合成的响应对象(缺少 loaded/total/lengthComputable) @@ -793,9 +797,11 @@ export function GM_xmlhttpRequest( break; // upload 事件均为 ProgressEvent (标准语义),统一携带 loaded/total/lengthComputable case "onuploadloadstart": + lastUploadEventData = data; details.upload?.onloadstart?.(makeProgressCallbackParam(data)); break; case "onuploadprogress": + lastUploadEventData = data; details.upload?.onprogress?.(makeProgressCallbackParam(data)); break; case "onuploadload": diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index e89cb2b05..dbd08f6b3 100644 --- a/src/template/scriptcat.d.tpl +++ b/src/template/scriptcat.d.tpl @@ -616,6 +616,16 @@ declare namespace GMTypes { type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; + interface XHRUpload { + onloadstart?: Listener; + onprogress?: Listener; + onload?: Listener; + onloadend?: Listener; + onerror?: Listener; + onabort?: Listener; + ontimeout?: Listener; + } + interface XHRDetails { method?: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; url: string | URL | File | Blob; @@ -638,6 +648,7 @@ declare namespace GMTypes { cookiePartition?: Record & { topLevelSite?: string; // 表示分区 cookie 的顶部帧站点 }; // 包含用于发送和接收的分区 cookie 的分区键 https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies#storage_partitioning + upload?: XHRUpload; context?: any; // 自定义值,传递给响应的 response.context 属性 onload?: Listener; From d067b090674fd73ed85b74603efaec5b5c735eab Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:10:25 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=F0=9F=90=9B=20preserve=20abort=20state?= =?UTF-8?q?=20for=20upload=20callbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_xhr.test.ts | 3 +++ src/app/service/content/gm_api/gm_xhr.ts | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts index 71683c1a7..0b49a7bf9 100644 --- a/src/app/service/content/gm_api/gm_xhr.test.ts +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -550,6 +550,9 @@ describe("GM_xmlhttpRequest 的 upload 事件派发", () => { for (const fn of [onUploadAbort, onUploadLoadEnd]) { expect(fn).toHaveBeenCalledTimes(1); const arg = fn.mock.calls[0][0]; + expect(arg.readyState).toBe(0); + expect(arg.status).toBe(0); + expect(arg.error).toBe("aborted"); expect(arg.loaded).toBe(5 * 1024 * 1024); expect(arg.total).toBe(10 * 1024 * 1024); expect(arg.lengthComputable).toBe(true); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index c2486aa3f..8ad812efd 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -607,11 +607,11 @@ export function GM_xmlhttpRequest( // 对齐原生 XHR abort() 语义:upload 尚未完成时,先补发 upload 的 abort; // 若已收到进度事件,则沿用最后一次真实进度数据 uploadDone = true; - const uploadEventData = lastUploadEventData ?? { + const uploadEventData = { ...data, - lengthComputable: false, - loaded: 0, - total: 0, + lengthComputable: lastUploadEventData?.lengthComputable ?? false, + loaded: lastUploadEventData?.loaded ?? 0, + total: lastUploadEventData?.total ?? 0, }; details.upload?.onabort?.(makeProgressCallbackParam(uploadEventData)); fireUploadLoadEnd(uploadEventData);