diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 56e16b974..21bc5407e 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -9,48 +9,66 @@ 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(); - 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/"); - 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: 30_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], 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..0b49a7bf9 --- /dev/null +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -0,0 +1,631 @@ +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("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; + + 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 回调时,即使 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); + 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(); + 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 并携带 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 阶段尚未完成(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, + 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.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, + }, + } 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 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, + onloadend: onUploadLoadEnd, + }, + } as unknown as GMTypes.XHRDetails; + + const { abort } = GM_xmlhttpRequest(api, details, false); + await waitTick(); + const messageHandler = getMessageHandler(); + + messageHandler!({ + action: "onuploadloadend", + data: { + finalUrl: "", + readyState: 3, + status: 0, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "uploadloadend", + ok: false, + contentType: "", + loaded: 10, + total: 10, + lengthComputable: true, + }, + }); + await waitTick(); + expect(onUploadLoadEnd).toHaveBeenCalledTimes(1); + + abort(); + await waitTick(); + + expect(onUploadAbort).not.toHaveBeenCalled(); + expect(onUploadLoadEnd).toHaveBeenCalledTimes(1); + 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", + method: "POST", + data: "payload", + 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("在 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", + method: "POST", + data: "payload", + 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", + method: "POST", + data: "payload", + [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(); + 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; + + 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.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.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); + } + }); + + it("从未完成的 upload 阶段调用 abort() 时,事件应按 upload.abort → upload.loadend → 主 abort → 主 loadend 的顺序触发", async () => { + const { api } = createFakeApi(); + 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: { + 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(); + 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..8ad812efd 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -128,6 +128,29 @@ 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 功能的脚本造成额外的预检开销/兼容性影响。 +// 只接受函数值: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, + ].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 { @@ -172,6 +195,12 @@ 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; + // details.upload 在请求生命周期内不会变化,只需计算一次,供下方 canHaveUploadLifecycle 与 abort() 共用 + const uploadHasHandlers = hasAnyUploadHandler(details.upload); if (a.isInvalidContext()) { return { retPromise: requirePromise ? Promise.reject("GM_xmlhttpRequest: Invalid Context") : null, @@ -215,6 +244,7 @@ export function GM_xmlhttpRequest( password: details.password, redirect: details.redirect, fetch: details.fetch, + // hasUpload 需等待请求体编码完成、确认方法/请求体真的会产生 upload 阶段后才能计算,见下方 IIFE }; if (!param.headers) { param.headers = {}; @@ -231,6 +261,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 && + uploadHasHandlers; + param.hasUpload = canHaveUploadLifecycle; + // 处理返回数据 const isStreamResponse = responseTypeOriginal === "stream"; let readerStream: ReadableStream> | undefined; @@ -500,7 +543,42 @@ export function GM_xmlhttpRequest( return makeResponseRet(retParam, addGetters, res.contentType); }; let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; + // progress 事件均为 ProgressEvent (标准语义),统一携带 loaded/total/lengthComputable; + // 主请求 onprogress 与 upload.* 事件共用同一套字段拼装逻辑 + const makeProgressCallbackParam = ( + progressData: TXhrCallBackArg & { lengthComputable?: boolean; loaded?: number; total?: number } + ) => ({ + ...(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; + // uploadDone 只在 canHaveUploadLifecycle 为 true 时才会被读取(见下方 doAbort), + // 请求不会产生真实 upload 阶段时该标志天然不参与判断 + let uploadDone = false; + 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 = ( + data: TXhrCallBackArg & { lengthComputable?: boolean; loaded?: number; total?: number } + ) => { + if (!uploadLoadEndCalled) { + uploadLoadEndCalled = true; + details.upload?.onloadend?.(makeProgressCallbackParam(data)); + } + }; const doLoadEnd = (data: TXhrCallBackArg) => { if (!loadendCalled) { loadendCalled = true; @@ -521,6 +599,28 @@ export function GM_xmlhttpRequest( doAbort = (data: TXhrCallBackArg) => { if (!reqDone) { errorOccur = "AbortError"; + // canHaveUploadLifecycle 为 false 时(GET/HEAD、无请求体、或走 fetch 传输)根本不存在原生 + // upload 阶段,不应触发任何 upload.* 回调;只有真正可能产生 upload 阶段的请求才需要处理 + if (canHaveUploadLifecycle) { + const uploadWasDone = uploadDone; + if (!uploadWasDone) { + // 对齐原生 XHR abort() 语义:upload 尚未完成时,先补发 upload 的 abort; + // 若已收到进度事件,则沿用最后一次真实进度数据 + uploadDone = true; + const uploadEventData = { + ...data, + lengthComputable: lastUploadEventData?.lengthComputable ?? false, + loaded: lastUploadEventData?.loaded ?? 0, + total: lastUploadEventData?.total ?? 0, + }; + details.upload?.onabort?.(makeProgressCallbackParam(uploadEventData)); + fireUploadLoadEnd(uploadEventData); + } else { + // upload 已经成功完成:兜底补发的 onloadend 使用最后一次真实进度数据, + // 而非 abort() 合成的响应对象(缺少 loaded/total/lengthComputable) + fireUploadLoadEnd(lastUploadEventData ?? data); + } + } details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; // 不要进行 refCleanup !要等待最后的 onloadend @@ -657,15 +757,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; } @@ -703,6 +795,45 @@ export function GM_xmlhttpRequest( case "onabort": doAbort?.(data); 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": + // 对齐规范:upload complete flag 在 load 派发前已置位,须先于回调设置, + // 否则回调内同步调用 abort() 会误判 upload 尚未完成 + uploadDone = true; + // 记录真实进度数据:若回调内同步调用 abort(),兜底补发的 onloadend 需要这份数据, + // 而不是 abort() 合成响应对象里缺省的 undefined + lastUploadEventData = data; + details.upload?.onload?.(makeProgressCallbackParam(data)); + break; + case "onuploadloadend": + uploadDone = true; + fireUploadLoadEnd(data); + break; + case "onuploaderror": + case "onuploadabort": + case "onuploadtimeout": + // 对齐规范:request error steps 在派发 upload 的 error/abort/timeout 前, + // 已将整个请求置为 DONE——此时再调用 abort() 不会产生新事件(原生行为即为空操作)。 + // 让返回的 abort() 变为空操作,使真实的主 onerror/ontimeout/onabort 消息能够到达, + // 而不是被这里同步调用 abort() 时合成的 AbortError 抢先覆盖。 + uploadDone = true; + suppressSyntheticAbort = true; + if (msgData.action === "onuploaderror") { + details.upload?.onerror?.(makeProgressCallbackParam(data)); + } else if (msgData.action === "onuploadabort") { + details.upload?.onabort?.(makeProgressCallbackParam(data)); + } else { + details.upload?.ontimeout?.(makeProgressCallbackParam(data)); + } + break; // case "onstream": // controller?.enqueue(new Uint8Array(data)); // break; @@ -721,11 +852,16 @@ export function GM_xmlhttpRequest( return { retPromise, abort: () => { + if (suppressSyntheticAbort) { + // upload 阶段已以 error/abort/timeout 结束,整个请求已注定失败:保持通道开启, + // 让真实的主 onerror/ontimeout/onabort 消息自然到达并驱动正确的回调 + return; + } if (connect) { connect.disconnect(true); // 断开连结(容忍已断开) connect = null; } - if (doAbort && details.onabort && !reqDone) { + 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?.({ 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..c2299b69a --- /dev/null +++ b/src/pkg/utils/xhr/bg_gm_xhr.test.ts @@ -0,0 +1,163 @@ +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 (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: "" }, + msgConn, + null, + "marker-1" + ); + bgGmXhr.do(); + await waitTick(); + const xhr = FakeXMLHttpRequest.instances[0]; + expect(xhr).toBeDefined(); + 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"); + + 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..dd4da3228 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`. Requires the native-XHR strategy; not supported whenever the request runs over `fetch()` (`fetch: true`, `redirect` set, `anonymous`/`mozAnon`, or `responseType: "stream"`). | * * --- * ### Response Object @@ -392,6 +393,40 @@ export class BgGMXhr { baseXHR.ontimeout = callback; baseXHR.onreadystatechange = callback; baseXHR.onloadend = callback; + + // upload 阶段事件:仅原生 XMLHttpRequest 提供 xhr.upload,fetch 模式不支持上传进度回调 + // 只在调用方确实注册了 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}`; + 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/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; 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 85a45b9d0..a86887b8b 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -655,6 +655,20 @@ declare namespace GMTypes { type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; + /** + * 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; + 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 +697,12 @@ 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`. + * 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; onloadstart?: Listener; diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 4c99999cb..32bfe0c1e 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -661,6 +661,20 @@ declare namespace GMTypes { type GMXHRDataType = string | Blob | File | BufferSource | FormData | URLSearchParams; + /** + * 上传阶段的事件处理器,对齐 `XMLHttpRequestUpload`。 + * 依照 XHR 规范,upload 的所有事件均为 progress event,因此各回调统一接收 `XHRProgress`。 + */ + 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 +703,12 @@ declare namespace GMTypes { redirect?: "follow" | "error" | "manual"; /** 分区 Cookie 键,用于存储分区。 */ cookiePartition?: Record & { topLevelSite?: string }; + /** + * 上传阶段的事件处理器,对齐 `XMLHttpRequestUpload`。 + * 仅原生 XHR 策略支持:当请求改走 fetch()(`fetch: true`、设置了 `redirect`、`anonymous`/`mozAnon`, + * 或 `responseType: "stream"`)时不支持。 + */ + 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", () => {