diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 945d5e936..ddc640e77 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -991,6 +991,85 @@ export default class GMApi extends GM_Base { } return retParam as K; }; + // 保证「主回调」(onload/onerror/ontimeout) 与 onloadend 各自独立执行,且无论是否抛错, + // settle(resolve/reject retPromise,必要时 releaseResources)最终都会被调用一次。 + // onError 用于 native xhr 的 ontimeout/onerror:不能让使用者回调的例外同步传播回 + // GM_xmlhttpRequest 的内部消息处理循环(会中断其状态机,导致内部请求永久卡住), + // 改为异步重新抛出,让例外仍可被观察到(如全局错误上报),但不阻断内部生命周期收尾。 + const withLoadEnd = ( + primary: () => void, + loadendData: Record, + settle: () => void, + onError?: (err: unknown) => void + ) => { + try { + try { + primary(); + } finally { + details.onloadend?.(makeCallbackParam(loadendData)); + } + } catch (err) { + if (onError) { + onError(err); + } else { + throw err; + } + } finally { + settle(); + } + }; + // browser 与 native 两条路径最终都经由后台 GM_download 回传同一组消息(onload/save_cancelled/ + // onerror),此处共用同一份处理逻辑;releaseResources 仅 native 路径需要,browser 路径不传。 + const handleDownloadMessage = (data: { action?: string; data?: any }, releaseResources?: () => void) => { + switch (data.action) { + case "onload": + case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 + withLoadEnd( + () => details.onload?.(makeCallbackParam({ ...data.data })), + { ...data.data }, + () => { + retPromiseResolve?.(data.data); + releaseResources?.(); + } + ); + break; + case "onprogress": + details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" })); + retPromiseReject?.(new Error("Timeout ERROR")); + break; + case "ontimeout": + withLoadEnd( + () => details.ontimeout?.(makeCallbackParam({})), + {}, + () => { + retPromiseReject?.(new Error("Timeout ERROR")); + releaseResources?.(); + } + ); + break; + case "onerror": + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + { error: "unknown" }, + () => { + retPromiseReject?.(new Error("Unknown ERROR")); + releaseResources?.(); + } + ); + break; + default: + LoggerCore.logger().warn("GM_download resp is error", { data }); + withLoadEnd( + () => {}, + {}, + () => { + retPromiseReject?.(new Error("Unexpected Internal ERROR")); + releaseResources?.(); + } + ); + break; + } + }; const handle = async () => { const url = await urlPromiseLike; const downloadMode = details.downloadMode || "native"; // native = sc_default; browser = chrome api @@ -1007,60 +1086,49 @@ export default class GMApi extends GM_Base { // ignored } } - const con = await a.connect("GM_download", [ - { - method: details.method, - downloadMode: "browser", // 默认使用xhr下载 - url: url as string, - name: details.name, - headers: details.headers, - saveAs: details.saveAs, - conflictAction: details.conflictAction, - timeout: details.timeout, - cookie: details.cookie, - anonymous: details.anonymous, - } as GMTypes.DownloadDetails, - ]); + let con: MessageConnect; + try { + con = await a.connect("GM_download", [ + { + method: details.method, + downloadMode: "browser", // 默认使用xhr下载 + url: url as string, + name: details.name, + headers: details.headers, + saveAs: details.saveAs, + conflictAction: details.conflictAction, + timeout: details.timeout, + cookie: details.cookie, + anonymous: details.anonymous, + } as GMTypes.DownloadDetails, + ]); + } catch (e) { + // 后台连接失败:通过 onerror / reject 通知调用方,行为与 “onMessage 收到 onerror” 一致, + // 否则 GM.download 的 promise 会永远 pending(issue: 无 native XHR 后备路径可兜底)。 + if (!aborted) { + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + { error: "unknown" }, + () => retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")) + ); + } + return; + } if (aborted) return; connect = con; - connect.onMessage((data) => { - switch (data.action) { - case "onload": - details.onload?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - break; - case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 - details.onload?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - break; - case "onprogress": - details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" })); - retPromiseReject?.(new Error("Timeout ERROR")); - break; - case "ontimeout": - details.ontimeout?.(makeCallbackParam({})); - retPromiseReject?.(new Error("Timeout ERROR")); - break; - case "onerror": - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - retPromiseReject?.(new Error("Unknown ERROR")); - break; - default: - LoggerCore.logger().warn("GM_download resp is error", { - data, - }); - retPromiseReject?.(new Error("Unexpected Internal ERROR")); - break; - } - }); + connect.onMessage((data) => handleDownloadMessage(data)); } else { // native + // xhr 已因 ontimeout/onerror 失败:即使失败前已收到部分数据,底层 XHR 实现仍会在其 + // onloadend 中带上由已收数据拼出的 Blob。必须以此标志位拦截,否则会把这段部分数据 + // 当作下载成功,误经 chrome.downloads 落盘为被截断的文件。 + let xhrFailed = false; const xhrParams = { url: url, fetch: true, // 跟随TM使用 fetch; 使用 fetch 避免 1) 大量数据存放offscreen xhr 2) vivaldi offscreen client block responseType: "blob", onloadend: async (res) => { - if (aborted) return; + if (aborted || xhrFailed) return; const response = res.response; if (!(response instanceof Blob)) return; @@ -1099,8 +1167,11 @@ export default class GMApi extends GM_Base { // 行为与 “onMessage 收到 onerror” 一致,保持外层 contract 不变。 releaseResources(); if (!aborted) { - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")); + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + { error: "unknown" }, + () => retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR")) + ); } return; } @@ -1117,37 +1188,7 @@ export default class GMApi extends GM_Base { } connect = con; - connect.onMessage((data) => { - switch (data.action) { - case "onload": - details.onload?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - releaseResources(); - break; - case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功 - details.onload?.(makeCallbackParam({ ...data.data })); - retPromiseResolve?.(data.data); - releaseResources(); - break; - case "ontimeout": - details.ontimeout?.(makeCallbackParam({})); - retPromiseReject?.(new Error("Timeout ERROR")); - releaseResources(); - break; - case "onerror": - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); - retPromiseReject?.(new Error("Unknown ERROR")); - releaseResources(); - break; - default: - LoggerCore.logger().warn("GM_download resp is error", { - data, - }); - retPromiseReject?.(new Error("Unexpected Internal ERROR")); - releaseResources(); - break; - } - }); + connect.onMessage((data) => handleDownloadMessage(data, releaseResources)); // 后台主动断连(例如 SW 重启、扩展更新)也释放 URL,避免长尾泄漏。 // releaseResources 通过 released 标志位幂等,与 onMessage 内部的释放调用顺序无关。 @@ -1162,10 +1203,39 @@ export default class GMApi extends GM_Base { details.onprogress?.(makeCallbackParam({ ...e, mode: "native" })); }, ontimeout: () => { - details.ontimeout?.(makeCallbackParam({})); + xhrFailed = true; + // XHR 阶段已终结:清空 nativeAbort,避免使用者在 onloadend 内呼叫 + // download.abort() 时经由 nativeAbort() 强制断开内部 XHR 的消息连线 + // (其 abort() 会无条件 disconnect),导致内部 XHR 收不到自己真正的 + // onloadend 消息、其 retPromise 永久 pending、refCleanup 也无法执行。 + // 清空后 abort() 中的 nativeAbort?.() 变为空操作,内部 XHR 可自然收尾。 + nativeAbort = null; + // 必须在此直接 settle:若使用者在 onloadend 内呼叫 abort(),只会设置 aborted + // 旗标,不代表这次下载没有失败。原本仅靠 GM_xmlhttpRequest 内部 retPromise 之后 + // 才 reject 的路径,会被外层 `if (aborted) return` 短路,导致 GM.download 的 + // promise 永久 pending。settle 具幂等性,重复调用是安全的。 + withLoadEnd( + () => details.ontimeout?.(makeCallbackParam({})), + {}, + () => retPromiseReject?.(new Error("Native Download ERROR")), + (err) => + queueMicrotask(() => { + throw err; + }) + ); }, onerror: () => { - details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError); + xhrFailed = true; + nativeAbort = null; + withLoadEnd( + () => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError), + { error: "unknown" }, + () => retPromiseReject?.(new Error("Native Download ERROR")), + (err) => + queueMicrotask(() => { + throw err; + }) + ); }, } as GMTypes.XHRDetails; if (typeof details.headers === "object") { diff --git a/src/app/service/content/gm_api/gm_download.test.ts b/src/app/service/content/gm_api/gm_download.test.ts new file mode 100644 index 000000000..78779be0e --- /dev/null +++ b/src/app/service/content/gm_api/gm_download.test.ts @@ -0,0 +1,420 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MessageConnect } from "@Packages/message/types"; +import GMApi from "./gm_api"; +import { GM_xmlhttpRequest } from "./gm_xhr"; + +vi.mock("./gm_xhr", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + GM_xmlhttpRequest: vi.fn((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.onerror?.(); + }); + return { retPromise: Promise.resolve(), abort: vi.fn() }; + }), + }; +}); + +async function flushMicrotasks(times = 10) { + for (let i = 0; i < times; i++) { + await Promise.resolve(); + } +} + +function createFakeConnect() { + let messageHandler: ((data: any) => void) | undefined; + const conn = { + onMessage(cb: (data: any) => void) { + messageHandler = cb; + }, + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + return { + conn, + emit(data: any) { + messageHandler?.(data); + }, + }; +} + +function createFakeA(conn: MessageConnect) { + return { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(conn), + }; +} + +describe.concurrent("GM_download onloadend", () => { + it.concurrent("downloadMode=browser:onload 触发后应同时调用 onloadend,且携带相同数据", async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const onload = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onload, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + const payload = { loaded: 10, total: 10, mode: "native" }; + emit({ action: "onload", data: payload }); + + expect(onload).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledWith(expect.objectContaining(payload)); + }); + + it.concurrent( + "downloadMode=browser:save_cancelled 触发后应同时调用 onload 与 onloadend(TM 视为成功)", + async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const onload = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onload, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + emit({ action: "save_cancelled", data: { loaded: 5, total: 10 } }); + + expect(onload).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + } + ); + + it.concurrent("downloadMode=browser:ontimeout 触发后应同时调用 ontimeout 与 onloadend", async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const ontimeout = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + ontimeout, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + emit({ action: "ontimeout" }); + + expect(ontimeout).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it.concurrent("downloadMode=browser:onerror 触发后应同时调用 onerror 与 onloadend", async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const onerror = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onerror, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + emit({ action: "onerror" }); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it.concurrent("downloadMode=native:xhr 阶段 onerror(未取得 blob)应同时调用 onerror 与 onloadend", async () => { + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn(), + }; + const onerror = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onerror, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + expect(fakeA.connect).not.toHaveBeenCalled(); + }); +}); + +// 非 concurrent:以下用例通过 mockImplementationOnce 定制 GM_xmlhttpRequest 的行为, +// 与 describe.concurrent 中并发触发的默认 mock 共享同一队列会有竞态,因此单独放在顺序执行的 describe 中。 +describe("GM_download 补充回归测试(native 部分下载 / browser connect 失败 / onloadend 抛错)", () => { + it("native 模式:onerror 后 xhr onloadend 携带非空 Blob,不应触发浏览器下载", async () => { + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn(), + }; + const onerror = vi.fn(); + const onloadend = vi.fn(); + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.onerror?.(); + xhrParams.onloadend?.({ response: new Blob(["partial data"]) }); + }); + return { retPromise: Promise.resolve(), abort: vi.fn() }; + }); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onerror, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + expect(fakeA.connect).not.toHaveBeenCalled(); + }); + + it("native 模式:ontimeout 后 xhr onloadend 携带非空 Blob,不应触发浏览器下载", async () => { + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn(), + }; + const ontimeout = vi.fn(); + const onloadend = vi.fn(); + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.ontimeout?.(); + xhrParams.onloadend?.({ response: new Blob(["partial data"]) }); + }); + return { retPromise: Promise.resolve(), abort: vi.fn() }; + }); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + ontimeout, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + expect(ontimeout).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + expect(fakeA.connect).not.toHaveBeenCalled(); + }); + + it("downloadMode=browser:a.connect 失败应触发 onerror/onloadend 并 reject retPromise", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const connectError = new Error("connect failed"); + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn().mockRejectedValue(connectError), + }; + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onerror, + onloadend, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + await expect(retPromise).rejects.toBe(connectError); + }); + + it("downloadMode=browser:onload 触发时 onloadend 抛错,不应阻止 retPromise resolve", async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const onloadend = vi.fn(() => { + throw new Error("boom"); + }); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onloadend, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + const payload = { loaded: 10, total: 10 }; + expect(() => emit({ action: "onload", data: payload })).toThrow("boom"); + + await expect(retPromise).resolves.toEqual(expect.objectContaining(payload)); + }); + + it("downloadMode=native:下载成功后 onloadend 抛错,不应阻止 retPromise resolve 与 releaseResources(revokeObjectURL)", async () => { + const revokeSpy = vi.spyOn(URL, "revokeObjectURL"); + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.onloadend?.({ response: new Blob(["full data"]) }); + }); + return { retPromise: Promise.resolve(), abort: vi.fn() }; + }); + const onloadend = vi.fn(() => { + throw new Error("boom"); + }); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onloadend, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + const payload = { loaded: 10, total: 10 }; + expect(() => emit({ action: "onload", data: payload })).toThrow("boom"); + await expect(retPromise).resolves.toEqual(payload); + + await new Promise((r) => setTimeout(r, 5)); + expect(revokeSpy).toHaveBeenCalled(); + }); + + it("downloadMode=native:完整成功流程只触发一次 onload/onloadend,并释放 blob URL", async () => { + const revokeSpy = vi.spyOn(URL, "revokeObjectURL"); + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.onloadend?.({ response: new Blob(["full data"]) }); + }); + return { retPromise: Promise.resolve(), abort: vi.fn() }; + }); + const onload = vi.fn(); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onload, + onloadend, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + expect(fakeA.connect).toHaveBeenCalledTimes(1); + const payload = { loaded: 20, total: 20 }; + emit({ action: "onload", data: payload }); + + expect(onload).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + + await new Promise((r) => setTimeout(r, 5)); + expect(revokeSpy).toHaveBeenCalled(); + }); + + it("downloadMode=browser:onerror 主回调抛错,仍应调用 onloadend 并 reject retPromise", async () => { + const { conn, emit } = createFakeConnect(); + const fakeA = createFakeA(conn); + const onerror = vi.fn(() => { + throw new Error("boom"); + }); + const onloadend = vi.fn(); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "browser", + onerror, + onloadend, + }; + const { retPromise } = GMApi._GM_download(fakeA as any, details, true); + await flushMicrotasks(); + + expect(() => emit({ action: "onerror" })).toThrow("boom"); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + await expect(retPromise).rejects.toThrow("Unknown ERROR"); + }); + + it("native 模式:xhr 阶段失败后于用户 onloadend 内呼叫 abort(),retPromise 仍应 reject 而非永久 pending,且不应中断内部 XHR", async () => { + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn(), + }; + const innerAbort = vi.fn(); + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + queueMicrotask(() => { + xhrParams.onerror?.(); + }); + // 模拟真实 GM_xmlhttpRequest:内部 retPromise 最终也会 reject(比 xhrParams.onerror 更晚触发)。 + return { retPromise: Promise.reject(new Error("mock xhr error")), abort: innerAbort }; + }); + const abortHolder: { fn?: () => void } = {}; + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onloadend: () => { + abortHolder.fn?.(); + }, + }; + const { retPromise, abort } = GMApi._GM_download(fakeA as any, details, true); + abortHolder.fn = abort; + + await expect(retPromise).rejects.toThrow(); + // XHR 阶段已失败:呼叫外层 abort() 不应再中断内部 XHR(否则内部 XHR 收不到 + // 自己真正的 onloadend 消息,其自身生命周期会永久卡住、无法完成清理)。 + expect(innerAbort).not.toHaveBeenCalled(); + }); + + it("native 模式:xhr 阶段用户回调抛错不应同步向上传播(避免打断 GM_xmlhttpRequest 内部状态机)", async () => { + const fakeA = { + isInvalidContext: () => false, + connect: vi.fn(), + }; + const capturedHandlers: { onerror?: () => void; ontimeout?: () => void } = {}; + vi.mocked(GM_xmlhttpRequest).mockImplementationOnce((_a: unknown, xhrParams: any) => { + capturedHandlers.onerror = xhrParams.onerror; + capturedHandlers.ontimeout = xhrParams.ontimeout; + return { retPromise: Promise.reject(new Error("mock xhr error")), abort: vi.fn() }; + }); + // 拦截 queueMicrotask:避免测试环境中真的抛出未捕获例外,同时保留对其排程内容的断言能力。 + const queueMicrotaskSpy = vi.spyOn(globalThis, "queueMicrotask").mockImplementation(() => {}); + const onerror = vi.fn(() => { + throw new Error("boom"); + }); + const details: GMTypes.DownloadDetails = { + url: "https://example.com/a.zip", + name: "a.zip", + downloadMode: "native", + onerror, + }; + GMApi._GM_download(fakeA as any, details, false); + await flushMicrotasks(); + + // 模拟 GM_xmlhttpRequest 内部(例如 code===-1 协议错误分支)同步呼叫 xhrParams.onerror(): + // 若此处同步抛出,会中断该分支后续的 reqDone 置位与合成 onloadend 排程。 + expect(() => capturedHandlers.onerror?.()).not.toThrow(); + expect(onerror).toHaveBeenCalledTimes(1); + expect(queueMicrotaskSpy).toHaveBeenCalledTimes(1); + const deferredThrow = queueMicrotaskSpy.mock.calls[0][0] as () => void; + expect(deferredThrow).toThrow("boom"); + + queueMicrotaskSpy.mockRestore(); + }); +}); diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index e89cb2b05..134af07f3 100644 --- a/src/template/scriptcat.d.tpl +++ b/src/template/scriptcat.d.tpl @@ -690,6 +690,8 @@ declare namespace GMTypes { totalSize: number; }>; ontimeout?: (arg1?: any) => void; + // 下载结束时触发(无论 onload/onerror/ontimeout 中的哪一个),在其之后调用。 + onloadend?: Listener; } interface NotificationThis extends NotificationDetails { diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 85a45b9d0..b0767365f 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -735,6 +735,8 @@ declare namespace GMTypes { totalSize: number; }>; ontimeout?: (arg1?: any) => void; + /** Fires after onload/onerror/ontimeout, once the download has finished either way. */ + onloadend?: Listener; } interface NotificationThis extends NotificationDetails { diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 4c99999cb..4c8eb5a6a 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -741,6 +741,8 @@ declare namespace GMTypes { totalSize: number; }>; ontimeout?: (arg1?: any) => void; + /** 下载结束时触发(无论 onload/onerror/ontimeout 中的哪一个),在其之后调用。 */ + onloadend?: Listener; } interface NotificationThis extends NotificationDetails {