From 76c1a28e983e303d5eb0b518058cf0407a1dd583 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 6 May 2026 23:24:36 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix:=20browser=20API=20`chrome.downloads.do?= =?UTF-8?q?wnload`=20=E4=BB=A3=E7=A0=81=E5=8F=8AMock=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/chrome-extension-mock/downloads.ts | 293 +++++++++++++++++- packages/chrome-extension-mock/index.ts | 4 +- .../service/service_worker/download.test.ts | 212 +++++++++++++ src/app/service/service_worker/download.ts | 167 ++++++++++ .../service/service_worker/gm_api/gm_api.ts | 59 ++-- src/app/service/service_worker/synchronize.ts | 3 +- 6 files changed, 685 insertions(+), 53 deletions(-) create mode 100644 src/app/service/service_worker/download.test.ts create mode 100644 src/app/service/service_worker/download.ts diff --git a/packages/chrome-extension-mock/downloads.ts b/packages/chrome-extension-mock/downloads.ts index 5a2d3d7b6..d03858066 100644 --- a/packages/chrome-extension-mock/downloads.ts +++ b/packages/chrome-extension-mock/downloads.ts @@ -1,20 +1,293 @@ +import EventEmitter from "eventemitter3"; + +type DownloadChangedListener = (downloadDelta: chrome.downloads.DownloadDelta) => void; +type DetermineFilenameListener = ( + downloadItem: chrome.downloads.DownloadItem, + suggest: (suggestion?: chrome.downloads.FilenameSuggestion) => void +) => void | boolean; + +type Callback = (value: T) => void; +type DownloadItem = chrome.downloads.DownloadItem & { + conflictAction?: `${chrome.downloads.FilenameConflictAction}`; +}; + export default class Downloads { - onChangedCallback: ((downloadDelta: chrome.downloads.DownloadDelta) => void) | null = null; + downloadIdAccum: number = 0; + hook = new EventEmitter(); + items = new Map(); + autoComplete = true; + autoCompleteDelay = 1; onChanged = { - addListener: (callback: (downloadDelta: chrome.downloads.DownloadDelta) => void) => { - this.onChangedCallback = callback; + addListener: (callback: DownloadChangedListener) => { + this.hook.addListener("onChanged", callback); + }, + removeListener: (callback: DownloadChangedListener) => { + this.hook.removeListener("onChanged", callback); + }, + hasListener: (callback: DownloadChangedListener) => this.hook.listeners("onChanged").includes(callback), + hasListeners: () => this.hook.listenerCount("onChanged") > 0, + }; + + onDeterminingFilename = { + addListener: (callback: DetermineFilenameListener) => { + this.hook.addListener("onDeterminingFilename", callback); }, - removeListener: (_callback: (downloadDelta: chrome.downloads.DownloadDelta) => void) => { - this.onChangedCallback = null; + removeListener: (callback: DetermineFilenameListener) => { + this.hook.removeListener("onDeterminingFilename", callback); }, + hasListener: (callback: DetermineFilenameListener) => + this.hook.listeners("onDeterminingFilename").includes(callback), + hasListeners: () => this.hook.listenerCount("onDeterminingFilename") > 0, }; - download(_: any, callback: (downloadId: number) => void) { - callback && callback(1); - this.onChangedCallback?.({ - id: 1, - state: { current: "complete" }, + reset() { + this.downloadIdAccum = 0; + this.items.clear(); + this.hook.removeAllListeners(); + this.autoComplete = true; + this.autoCompleteDelay = 1; + this.clearLastError(); + } + + download(options: chrome.downloads.DownloadOptions, callback?: Callback) { + this.clearLastError(); + if (!options?.url) { + const error = new Error("The download url is required."); + if (callback) { + this.withLastError(error.message, () => callback(undefined as unknown as number)); + return; + } + return Promise.reject(error); + } + + const id = ++this.downloadIdAccum; + const item = this.createDownloadItem(id, options); + this.items.set(id, item); + + // Chrome 会先把 id 返回给调用方,随后才进入文件名决定和状态变化事件。 + const delayed = async () => { + await this.determineFilename(item); + if (this.autoComplete && item.state === "in_progress") { + this.complete(id); + } + }; + + if (callback) { + callback(id); + setTimeout(delayed, this.autoCompleteDelay); + return; + } + return new Promise((resolve) => { + resolve(id); + setTimeout(delayed, this.autoCompleteDelay); }); } + + cancel(downloadId: number, callback?: () => void) { + this.clearLastError(); + const item = this.items.get(downloadId); + if (!item) return this.maybeAsync(undefined, callback); + if (item.state === "in_progress") { + item.state = "interrupted"; + item.error = "USER_CANCELED"; + item.endTime = new Date().toISOString(); + this.emitChanged({ + id: downloadId, + state: { previous: "in_progress", current: "interrupted" }, + error: { current: "USER_CANCELED" }, + }); + } + return this.maybeAsync(undefined, callback); + } + + search(query: chrome.downloads.DownloadQuery, callback?: Callback) { + this.clearLastError(); + const result = [...this.items.values()].filter((item) => this.matchQuery(item, query)); + return this.maybeAsync(result, callback); + } + + erase(query: chrome.downloads.DownloadQuery, callback?: Callback) { + this.clearLastError(); + const ids = [...this.items.values()].filter((item) => this.matchQuery(item, query)).map((item) => item.id); + ids.forEach((id) => this.items.delete(id)); + return this.maybeAsync(ids, callback); + } + + pause(downloadId: number, callback?: () => void) { + this.clearLastError(); + const item = this.items.get(downloadId); + if (item && item.state === "in_progress" && !item.paused) { + item.paused = true; + this.emitChanged({ + id: downloadId, + paused: { previous: false, current: true }, + }); + } + return this.maybeAsync(undefined, callback); + } + + resume(downloadId: number, callback?: () => void) { + this.clearLastError(); + const item = this.items.get(downloadId); + if (item && item.paused) { + item.paused = false; + this.emitChanged({ + id: downloadId, + paused: { previous: true, current: false }, + }); + } + return this.maybeAsync(undefined, callback); + } + + show(_downloadId: number) { + this.clearLastError(); + } + + showDefaultFolder() { + this.clearLastError(); + } + + open(_downloadId: number, callback?: () => void) { + this.clearLastError(); + return this.maybeAsync(undefined, callback); + } + + removeFile(_downloadId: number, callback?: () => void) { + this.clearLastError(); + return this.maybeAsync(undefined, callback); + } + + complete(downloadId: number) { + const item = this.items.get(downloadId); + if (!item || item.state !== "in_progress") return; + item.state = "complete"; + item.bytesReceived = item.totalBytes >= 0 ? item.totalBytes : item.bytesReceived; + item.endTime = new Date().toISOString(); + this.emitChanged({ + id: downloadId, + state: { previous: "in_progress", current: "complete" }, + }); + } + + interrupt(downloadId: number, error: `${chrome.downloads.InterruptReason}` = "NETWORK_FAILED") { + const item = this.items.get(downloadId); + if (!item || item.state !== "in_progress") return; + item.state = "interrupted"; + item.error = error; + item.endTime = new Date().toISOString(); + this.emitChanged({ + id: downloadId, + state: { previous: "in_progress", current: "interrupted" }, + error: { current: error }, + }); + } + + private createDownloadItem(id: number, options: chrome.downloads.DownloadOptions): DownloadItem { + const filename = options.filename || this.inferFilename(options.url); + return { + id, + url: options.url, + finalUrl: options.url, + referrer: "", + filename, + danger: "safe", + mime: "", + startTime: new Date().toISOString(), + endTime: undefined, + estimatedEndTime: undefined, + state: "in_progress", + paused: false, + canResume: false, + error: undefined, + bytesReceived: 0, + totalBytes: -1, + fileSize: -1, + exists: true, + byExtensionId: globalThis.chrome?.runtime?.id, + byExtensionName: "ScriptCat Mock", + incognito: false, + conflictAction: options.conflictAction, + } as DownloadItem; + } + + private inferFilename(url: string) { + try { + const pathname = new URL(url).pathname; + return decodeURIComponent(pathname.split("/").filter(Boolean).pop() || "download"); + } catch { + return "download"; + } + } + + private async determineFilename(item: DownloadItem) { + const listeners = this.hook.listeners("onDeterminingFilename") as DetermineFilenameListener[]; + if (listeners.length === 0) return; + + const suggestion = await new Promise((resolve) => { + let settled = false; + const suggest = (value?: chrome.downloads.FilenameSuggestion) => { + if (settled) return; + settled = true; + resolve(value); + }; + listeners.forEach((listener) => listener({ ...item } as chrome.downloads.DownloadItem, suggest)); + setTimeout(() => suggest(), 50); + }); + + if (suggestion?.filename) { + const previous = item.filename; + item.filename = suggestion.filename; + item.conflictAction = suggestion.conflictAction; + this.emitChanged({ + id: item.id, + filename: { previous, current: suggestion.filename }, + }); + } + } + + private matchQuery(item: DownloadItem, query: chrome.downloads.DownloadQuery) { + if (query.id !== undefined && item.id !== query.id) return false; + if (query.url && item.url !== query.url) return false; + if (query.filename && item.filename !== query.filename) return false; + if (query.state && item.state !== query.state) return false; + return true; + } + + private emitChanged(delta: chrome.downloads.DownloadDelta) { + this.hook.emit("onChanged", delta); + } + + private maybeAsync(value: T, callback?: Callback) { + if (callback) { + callback(value); + return; + } + return Promise.resolve(value); + } + + private withLastError(message: string, fn: () => void) { + ( + globalThis.chrome.runtime as typeof chrome.runtime & { + lastError?: chrome.runtime.LastError; + } + ).lastError = { + message, + }; + try { + fn(); + } finally { + this.clearLastError(); + } + } + + private clearLastError() { + if (globalThis.chrome?.runtime) { + delete ( + globalThis.chrome.runtime as typeof chrome.runtime & { + lastError?: chrome.runtime.LastError; + } + ).lastError; + } + } } diff --git a/packages/chrome-extension-mock/index.ts b/packages/chrome-extension-mock/index.ts index e5d2b5ba0..4d2727dd8 100644 --- a/packages/chrome-extension-mock/index.ts +++ b/packages/chrome-extension-mock/index.ts @@ -26,7 +26,9 @@ const chromeMock = { extension: new Extension(), userScripts: new MockUserScripts(), action: new Action(), - init() {}, + init() { + this.downloads.reset(); + }, }; export default chromeMock; diff --git a/src/app/service/service_worker/download.test.ts b/src/app/service/service_worker/download.test.ts new file mode 100644 index 000000000..3d138f395 --- /dev/null +++ b/src/app/service/service_worker/download.test.ts @@ -0,0 +1,212 @@ +import chromeMock from "@Packages/chrome-extension-mock"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { detachDownloadCallback, startDownload, type DownloadCallback } from "./download"; + +const downloadsMock = chromeMock.downloads as unknown as { + reset: () => void; + items: Map< + number, + chrome.downloads.DownloadItem & { + conflictAction?: `${chrome.downloads.FilenameConflictAction}`; + } + >; + autoComplete: boolean; + hook: { emit: (event: string, ...args: any[]) => boolean }; + complete: (downloadId: number) => void; + interrupt: (downloadId: number, error?: `${chrome.downloads.InterruptReason}`) => void; +}; + +const waitForDownloadEvents = () => new Promise((resolve) => setTimeout(resolve, 20)); +const waitForQueue = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("chrome-extension-mock downloads", () => { + beforeEach(() => { + downloadsMock.reset(); + }); + + it("支持 Promise 风格 download/search/erase,并记录接近 Chrome DownloadItem 的状态", async () => { + const id = await chrome.downloads.download({ + url: "https://example.com/files/report.txt", + conflictAction: "overwrite", + }); + + expect(id).toBe(1); + expect(await chrome.downloads.search({ id })).toMatchObject([ + { + id, + url: "https://example.com/files/report.txt", + filename: "report.txt", + state: "in_progress", + byExtensionId: chrome.runtime.id, + }, + ]); + + await waitForDownloadEvents(); + + expect(await chrome.downloads.search({ id, state: "complete" })).toHaveLength(1); + expect(await chrome.downloads.erase({ id })).toEqual([id]); + expect(await chrome.downloads.search({ id })).toEqual([]); + }); + + it("支持 callback 风格下载,并按 Chrome 顺序先返回 id 再触发完成事件", async () => { + const changed = vi.fn(); + chrome.downloads.onChanged.addListener(changed); + + const callback = vi.fn(); + chrome.downloads.download({ url: "https://example.com/a.user.js" }, callback); + + expect(callback).toHaveBeenCalledWith(1); + expect(changed).not.toHaveBeenCalled(); + + await waitForDownloadEvents(); + + expect(changed).toHaveBeenCalledWith( + expect.objectContaining({ + id: 1, + state: { previous: "in_progress", current: "complete" }, + }) + ); + }); +}); + +describe("startDownload", () => { + beforeEach(() => { + downloadsMock.reset(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(async () => { + await detachDownloadCallback(); + downloadsMock.reset(); + vi.restoreAllMocks(); + }); + + it("启动下载后返回 id,并在完成时调用回调和卸载监听", async () => { + const callback = vi.fn<(o: DownloadCallback) => void>(); + + const id = await startDownload({ url: "blob:https://scriptcat.test/1", filename: "exports/a.zip" }, callback); + + expect(id).toBe(1); + expect(chrome.downloads.onChanged.hasListeners()).toBe(true); + expect(chrome.downloads.onDeterminingFilename.hasListeners()).toBe(true); + + await waitForDownloadEvents(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ + donwloadId: id, + state: "complete", + }); + expect(chrome.downloads.onChanged.hasListeners()).toBe(false); + expect(chrome.downloads.onDeterminingFilename.hasListeners()).toBe(false); + }); + + it("通过 onDeterminingFilename 覆盖文件名,并保留指定 conflictAction", async () => { + const id = await startDownload({ + url: "blob:https://scriptcat.test/2", + filename: "脚本备份/backup.zip", + conflictAction: "overwrite", + }); + + await waitForDownloadEvents(); + + expect(downloadsMock.items.get(id!)?.filename).toBe("脚本备份/backup.zip"); + expect(downloadsMock.items.get(id!)?.conflictAction).toBe("overwrite"); + }); + + it("未指定 conflictAction 时默认使用 uniquify", async () => { + const id = await startDownload({ + url: "blob:https://scriptcat.test/3", + filename: "backup.zip", + }); + + await waitForDownloadEvents(); + + expect(downloadsMock.items.get(id!)?.filename).toBe("backup.zip"); + expect(downloadsMock.items.get(id!)?.conflictAction).toBe("uniquify"); + }); + + it("没有 filename 时不覆盖浏览器推断的文件名", async () => { + const id = await startDownload({ + url: "https://example.com/path/original.txt", + }); + + await waitForDownloadEvents(); + + expect(downloadsMock.items.get(id!)?.filename).toBe("original.txt"); + expect(downloadsMock.items.get(id!)?.conflictAction).toBeUndefined(); + }); + + it("下载中断时返回 interrupted 并清理监听", async () => { + downloadsMock.autoComplete = false; + const callback = vi.fn<(o: DownloadCallback) => void>(); + + const id = await startDownload({ url: "blob:https://scriptcat.test/4", filename: "fail.zip" }, callback); + downloadsMock.interrupt(id!, "NETWORK_FAILED"); + await waitForQueue(); + + expect(callback).toHaveBeenCalledWith({ + donwloadId: id, + state: "interrupted", + }); + expect(chrome.downloads.onChanged.hasListeners()).toBe(false); + expect(chrome.downloads.onDeterminingFilename.hasListeners()).toBe(false); + }); + + it("下载回调抛错时仍然清理监听并隔离异常", async () => { + const callback = vi.fn(() => { + throw new Error("callback failed"); + }); + + await startDownload( + { + url: "blob:https://scriptcat.test/callback-error", + filename: "error.zip", + }, + callback + ); + await waitForDownloadEvents(); + + expect(callback).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith("download callback error:", expect.any(Error)); + expect(chrome.downloads.onChanged.hasListeners()).toBe(false); + expect(chrome.downloads.onDeterminingFilename.hasListeners()).toBe(false); + }); + + it("忽略其他扩展发起的 onDeterminingFilename 事件", async () => { + downloadsMock.autoComplete = false; + const id = await startDownload({ + url: "blob:https://scriptcat.test/5", + filename: "expected.zip", + }); + + const suggest = vi.fn(); + const handled = chrome.downloads.onDeterminingFilename.hasListeners(); + downloadsMock.hook.emit( + "onDeterminingFilename", + { + id, + filename: "foreign.zip", + byExtensionId: "other-extension-id", + }, + suggest + ); + await waitForQueue(); + + expect(handled).toBe(true); + expect(suggest).not.toHaveBeenCalledWith(expect.objectContaining({ filename: "foreign.zip" })); + expect(downloadsMock.items.get(id!)?.filename).not.toBe("foreign.zip"); + + downloadsMock.complete(id!); + await waitForQueue(); + }); + + it("download API 失败时返回 undefined、输出错误并卸载监听", async () => { + const id = await startDownload({} as chrome.downloads.DownloadOptions); + + expect(id).toBeUndefined(); + expect(console.error).toHaveBeenCalled(); + expect(chrome.downloads.onChanged.hasListeners()).toBe(false); + expect(chrome.downloads.onDeterminingFilename.hasListeners()).toBe(false); + }); +}); diff --git a/src/app/service/service_worker/download.ts b/src/app/service/service_worker/download.ts new file mode 100644 index 000000000..2e2d1393f --- /dev/null +++ b/src/app/service/service_worker/download.ts @@ -0,0 +1,167 @@ +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; + +type FilenameConflictAction = `${chrome.downloads.FilenameConflictAction}`; +type DownloadOptions = chrome.downloads.DownloadOptions; + +// https://developer.chrome.com/docs/extensions/reference/api/downloads?hl=en#event-onDeterminingFilename +const onDeterminingFilename = ( + downloadItem: chrome.downloads.DownloadItem, + suggest: (suggestion?: chrome.downloads.FilenameSuggestion) => void +) => { + // 只处理本扩展发起的下载,避免覆盖用户或其他扩展创建的下载任务。 + if (downloadItem.byExtensionId !== chrome.runtime.id) { + // Each listener must call suggest exactly once, either synchronously or asynchronously. + suggest(); + return false; + } + let called = false; + stackAsyncTask("browser_api_download", () => { + try { + const entity = callbackMap.get(downloadItem.id); + const pendingOverride = entity?.nameOverride; + if (pendingOverride) { + // 文件名只需要在 onDeterminingFilename 消费一次;同一下载的后续事件仍保留 callback。 + entity.nameOverride = null; + // Chrome 建议用 suggest 改文件名,而不是只依赖 downloadOptions.filename。 + // 这样可以稳定处理 blob: URL、data: URL 等最终文件名不明确的下载。 + // 注:filename is ignored if there are any onDeterminingFilename listeners registered by any extensions. + suggest({ + filename: pendingOverride.filename, + // 默认 "uniquify":若存在同名文件,浏览器会自动追加 "(1)"。 + conflictAction: pendingOverride.conflictAction, + }); + called = true; + } + } catch { + // ignored + } + // 与当前逻辑无关的下载也必须调用 suggest,否则 Chrome 会认为事件未被消费。 + if (!called) { + suggest(); + called = true; + } + }); + // 如 suggest 已被调用,则回传 false + if (called) return false; + // 返回 true 表示会异步调用 suggest;否则 Chrome 可能提前结束文件名决策。 + return true; +}; + +const callbackMap = new Map< + number, + { + // 下载状态回调;下载完成或中断后会移除,避免 service worker 长期持有引用。 + callback: ((o: DownloadCallback) => any) | null; + // 待覆盖的目标文件名。只能消费一次,否则同一下载的重复事件可能重复 suggest。 + nameOverride: { + filename: string; + conflictAction: FilenameConflictAction; + } | null; + } +>(); + +const STATE = { + IN_PROGRESS: "in_progress", + INTERRUPTED: "interrupted", + COMPLETE: "complete", +} as const; + +type STATE = ValueOf; + +export type DownloadCallback = { + donwloadId: number; + state: STATE; +}; + +const notifyDownloadCallback = async (callback: ((o: DownloadCallback) => any) | null, payload: DownloadCallback) => { + try { + await callback?.(payload); + } catch (e) { + // 调用方回调失败不应破坏下载事件队列;下载记录已在调用前清理。 + console.error("download callback error:", e); + } +}; + +const onChangedListener = (downloadDelta: chrome.downloads.DownloadDelta) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.downloads.onChanged:", lastError); + return; + } + stackAsyncTask("browser_api_download", async () => { + const id = downloadDelta.id; + const entry = callbackMap.get(id); + if (!entry) return; + if (downloadDelta.state?.current === STATE.COMPLETE) { + detachDownloadCallback(id); + await notifyDownloadCallback(entry.callback, { + donwloadId: id, + state: STATE.COMPLETE, + }); + } else if (downloadDelta.state?.current === STATE.INTERRUPTED) { + detachDownloadCallback(id); + await notifyDownloadCallback(entry.callback, { + donwloadId: id, + state: STATE.INTERRUPTED, + }); + } + }); +}; + +const attachDownloadCallback = async () => { + try { + // 先移除再注册,防止 service worker 重载或测试重复导入时产生重复监听。 + chrome.downloads.onDeterminingFilename.removeListener(onDeterminingFilename); + chrome.downloads.onChanged.removeListener(onChangedListener); + } catch { + // ignored + } + chrome.downloads.onDeterminingFilename.addListener(onDeterminingFilename); + chrome.downloads.onChanged.addListener(onChangedListener); +}; + +export const detachDownloadCallback = async (downloadId: number | undefined = undefined) => { + if (downloadId !== undefined) callbackMap.delete(downloadId); + if (callbackMap.size === 0) { + // 没有待跟踪下载时及时卸载监听,减少后台常驻逻辑和误处理其他下载的风险。 + chrome.downloads.onDeterminingFilename.removeListener(onDeterminingFilename); + chrome.downloads.onChanged.removeListener(onChangedListener); + } +}; + +export const startDownload = async ( + downloadOptions: DownloadOptions, + callback: ((o: DownloadCallback) => any) | null = null +) => { + let mDownloadId: number | undefined = undefined; + if (callbackMap.size === 0) { + attachDownloadCallback(); + } + try { + mDownloadId = await stackAsyncTask("browser_api_download", async () => { + // chrome.downloads.download 会先返回 id,随后才触发 onDeterminingFilename/onChanged。 + // 因此拿到 id 后立即登记,后续事件才能找到对应的回调和文件名覆盖信息。 + const id = await chrome.downloads.download(downloadOptions); + id && + callbackMap.set(id, { + callback, + nameOverride: + downloadOptions.filename && typeof downloadOptions.filename === "string" + ? { + filename: downloadOptions.filename, + conflictAction: downloadOptions.conflictAction || "uniquify", + } + : null, + }); + return id; + }); + } catch (e) { + console.error(e); + } + if (chrome.runtime.lastError) { + mDownloadId = undefined; + console.error("chrome.runtime.lastError in chrome.downloads.download", chrome.runtime.lastError); + } + if (mDownloadId == undefined) detachDownloadCallback(); + return mDownloadId; +}; diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index c277ae598..2fe9b88f4 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -54,6 +54,8 @@ import { BgGMXhr } from "@App/pkg/utils/xhr/bg_gm_xhr"; import { mightPrepareSetClipboard, setClipboard } from "../clipboard"; import { nativePageWindowOpen } from "../../offscreen/gm_api"; import { nextSessionRuleId, removeSessionRuleIdEntry } from "./dnr_id_controller"; +import type { DownloadCallback } from "../download"; +import { detachDownloadCallback, startDownload } from "../download"; let generatedUniqueMarkerIDs = ""; let generatedUniqueMarkerIDWhen = ""; @@ -1244,21 +1246,15 @@ export default class GMApi { return this.GM_xmlhttpRequest(request satisfies GMApiRequest<[GMSend.XHRDetails?]>, sender); } let reqCompleteWith = ""; - let cDownloadId = 0; + let cDownloadId: number | undefined = 0; let isConnDisconnected = false; // 替换掉windows下文件名的非法字符为 - const fileName = cleanFileName(params.name); // blob本地文件或显示指定downloadMode为"browser"则直接下载 const blobURL = params.url; const respond = null; - const onChangedListener = (downloadDelta: chrome.downloads.DownloadDelta) => { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.downloads.onChanged:", lastError); - return; - } - if (!cDownloadId || downloadDelta.id !== cDownloadId) return; - if (downloadDelta.state?.current === "complete") { + const downloadCallback = (o: DownloadCallback) => { + if (o.state === "complete") { if (!isConnDisconnected && !reqCompleteWith) { reqCompleteWith = "ok"; msgConn.sendMessage({ @@ -1266,8 +1262,7 @@ export default class GMApi { data: respond, }); } - chrome.downloads.onChanged.removeListener(onChangedListener); - } else if (downloadDelta.state?.current === "interrupted") { + } else if (o.state === "interrupted") { if (!isConnDisconnected && !reqCompleteWith) { reqCompleteWith = "interrupted"; msgConn.sendMessage({ @@ -1275,21 +1270,20 @@ export default class GMApi { data: respond, }); } - chrome.downloads.onChanged.removeListener(onChangedListener); } }; msgConn.onDisconnect(() => { if (isConnDisconnected) return; isConnDisconnected = true; - if (cDownloadId > 0 && !reqCompleteWith) { + if (cDownloadId! > 0 && !reqCompleteWith) { reqCompleteWith = "disconnected"; - chrome.downloads.cancel(cDownloadId, () => { + chrome.downloads.cancel(cDownloadId!, () => { const lastError = chrome.runtime.lastError; if (lastError) { console.error("chrome.runtime.lastError in chrome.downloads.cancel:", lastError); } }); - chrome.downloads.onChanged.removeListener(onChangedListener); + detachDownloadCallback(cDownloadId); } }); if (!blobURL) { @@ -1314,33 +1308,16 @@ export default class GMApi { if (typeof params.conflictAction === "string") { downloadAPIOptions.conflictAction = params.conflictAction; } - chrome.downloads.onChanged.addListener(onChangedListener); - chrome.downloads.download(downloadAPIOptions, (downloadId: number | undefined) => { - const lastError = chrome.runtime.lastError; - let ok = true; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.downloads.download:", lastError); - // 下载API出现问题但继续执行 - ok = false; - } - if (downloadId === undefined) { - console.error("GM_download ERROR: API Failure for chrome.downloads.download."); - ok = false; - } - if (ok) { - cDownloadId = downloadId as number; - } - if (!ok) { - if (!isConnDisconnected && !reqCompleteWith) { - reqCompleteWith = "error:download_api_error"; - msgConn.sendMessage({ - action: "onerror", - data: respond, - }); - } - chrome.downloads.onChanged.removeListener(onChangedListener); + cDownloadId = await startDownload(downloadAPIOptions, downloadCallback); + if (cDownloadId === undefined) { + if (!isConnDisconnected && !reqCompleteWith) { + reqCompleteWith = "error:download_api_error"; + msgConn.sendMessage({ + action: "onerror", + data: respond, + }); } - }); + } } @PermissionVerify.API() diff --git a/src/app/service/service_worker/synchronize.ts b/src/app/service/service_worker/synchronize.ts index 1c032d3ab..ba99b3757 100644 --- a/src/app/service/service_worker/synchronize.ts +++ b/src/app/service/service_worker/synchronize.ts @@ -35,6 +35,7 @@ import i18n, { i18nName } from "@App/locales/locales"; import { InfoNotification } from "./utils"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { md5OfText } from "@App/pkg/utils/crypto"; +import { startDownload } from "./download"; // type SynchronizeTarget = "local"; @@ -267,7 +268,7 @@ export class SynchronizeService { const url = await makeBlobURL({ blob: zipOutput, persistence: false }, (params) => createObjectURL(this.msgSender, params) ); - chrome.downloads.download({ + startDownload({ url, saveAs: true, filename: `scriptcat-backup-${dayFormat(new Date(), "YYYY-MM-DDTHH-mm-ss")}.zip`, From 6f74c20d273d73e2fe7a2671f3856806e9e5b559 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Thu, 7 May 2026 07:45:20 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20`GM.download`=20?= =?UTF-8?q?=E5=9B=9E=E4=BC=A0=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.ts | 27 +++++-- .../service/service_worker/download.test.ts | 21 +++--- src/app/service/service_worker/download.ts | 70 ++++++++++++++----- .../service/service_worker/gm_api/gm_api.ts | 20 ++++-- 4 files changed, 103 insertions(+), 35 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index a98e9c1e8..4e959b4c4 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1043,8 +1043,9 @@ export default class GMApi extends GM_Base { responseType: "blob", onloadend: async (res) => { if (aborted) return; - if (res.response instanceof Blob) { - const url = URL.createObjectURL(res.response); // 生命周期跟随当前 content/page 而非 offscreen + const response = res.response; + if (response instanceof Blob) { + const url = URL.createObjectURL(response); // 生命周期跟随当前 content/page 而非 offscreen const con = await a.connect("GM_download", [ { method: details.method, @@ -1060,30 +1061,44 @@ export default class GMApi extends GM_Base { } as GMTypes.DownloadDetails, ]); if (aborted) return; + let released = false; + const releaseResources = () => { + if (released) return; + released = true; + setTimeout(() => { + // 释放不需要的 URL + URL.revokeObjectURL(url); + }, 1); + }; connect = con; connect.onMessage((data) => { switch (data.action) { case "onload": details.onload?.(makeCallbackParam({ ...data.data })); retPromiseResolve?.(data.data); - setTimeout(() => { - // 释放不需要的 URL - URL.revokeObjectURL(url); - }, 1); + releaseResources(); + break; + case "save_cancelled": // saveAs cancelled by user + 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; } }); diff --git a/src/app/service/service_worker/download.test.ts b/src/app/service/service_worker/download.test.ts index 3d138f395..256093c49 100644 --- a/src/app/service/service_worker/download.test.ts +++ b/src/app/service/service_worker/download.test.ts @@ -93,10 +93,13 @@ describe("startDownload", () => { await waitForDownloadEvents(); expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith({ - donwloadId: id, - state: "complete", - }); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + donwloadId: id, + state: "complete", + }) + ); + expect(chrome.downloads.onChanged.hasListeners()).toBe(false); expect(chrome.downloads.onDeterminingFilename.hasListeners()).toBe(false); }); @@ -145,10 +148,12 @@ describe("startDownload", () => { downloadsMock.interrupt(id!, "NETWORK_FAILED"); await waitForQueue(); - expect(callback).toHaveBeenCalledWith({ - donwloadId: id, - state: "interrupted", - }); + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + donwloadId: id, + state: expect.stringMatching(/interrupted|save_cancelled/), // 仅模拟错误 + }) + ); expect(chrome.downloads.onChanged.hasListeners()).toBe(false); expect(chrome.downloads.onDeterminingFilename.hasListeners()).toBe(false); }); diff --git a/src/app/service/service_worker/download.ts b/src/app/service/service_worker/download.ts index 2e2d1393f..7b19c77f2 100644 --- a/src/app/service/service_worker/download.ts +++ b/src/app/service/service_worker/download.ts @@ -17,7 +17,18 @@ const onDeterminingFilename = ( let called = false; stackAsyncTask("browser_api_download", () => { try { - const entity = callbackMap.get(downloadItem.id); + const id = downloadItem.id; + const entity = requestMap.get(id); + if (entity && !responseMap.has(id)) { + // just before saving. totalBytes and fileSize are known. bytesReceived = 0 + responseMap.set(id, { + downloadItem: { + bytesReceived: downloadItem.bytesReceived, + fileSize: downloadItem.fileSize, + totalBytes: downloadItem.totalBytes, + }, + }); + } const pendingOverride = entity?.nameOverride; if (pendingOverride) { // 文件名只需要在 onDeterminingFilename 消费一次;同一下载的后续事件仍保留 callback。 @@ -47,7 +58,7 @@ const onDeterminingFilename = ( return true; }; -const callbackMap = new Map< +const requestMap = new Map< number, { // 下载状态回调;下载完成或中断后会移除,避免 service worker 长期持有引用。 @@ -60,6 +71,13 @@ const callbackMap = new Map< } >(); +const responseMap = new Map< + number, + { + downloadItem: Partial; + } +>(); + const STATE = { IN_PROGRESS: "in_progress", INTERRUPTED: "interrupted", @@ -70,7 +88,9 @@ type STATE = ValueOf; export type DownloadCallback = { donwloadId: number; - state: STATE; + state: STATE | "save_cancelled"; + loaded?: number; + total?: number; }; const notifyDownloadCallback = async (callback: ((o: DownloadCallback) => any) | null, payload: DownloadCallback) => { @@ -90,19 +110,37 @@ const onChangedListener = (downloadDelta: chrome.downloads.DownloadDelta) => { } stackAsyncTask("browser_api_download", async () => { const id = downloadDelta.id; - const entry = callbackMap.get(id); + const entry = requestMap.get(id); if (!entry) return; - if (downloadDelta.state?.current === STATE.COMPLETE) { - detachDownloadCallback(id); - await notifyDownloadCallback(entry.callback, { - donwloadId: id, - state: STATE.COMPLETE, - }); - } else if (downloadDelta.state?.current === STATE.INTERRUPTED) { + const state = downloadDelta.state?.current; + if (state === STATE.COMPLETE || state === STATE.INTERRUPTED) { detachDownloadCallback(id); + let filenameConfirmed = false; + + if (!responseMap.has(id)) { + // onDeterminingFilename did not triggered. Fallback to chrome.downloads.search + const downloadItem = (await chrome.downloads.search({ id: id }))?.[0]; + if (downloadItem && downloadItem.id === id) { + responseMap.set(id, { + downloadItem: { + bytesReceived: downloadItem.bytesReceived, + fileSize: downloadItem.fileSize, + totalBytes: downloadItem.totalBytes, + }, + }); + } + } else { + // onDeterminingFilename was triggered + filenameConfirmed = true; + } + + const downloadItem = responseMap.get(id); + await notifyDownloadCallback(entry.callback, { donwloadId: id, - state: STATE.INTERRUPTED, + state: state === "interrupted" && filenameConfirmed ? "save_cancelled" : state, + loaded: downloadItem?.downloadItem?.totalBytes, // 兼容 TM,总是传回 totalBytes (与实际有否储存无关) + total: downloadItem?.downloadItem?.totalBytes, }); } }); @@ -121,8 +159,8 @@ const attachDownloadCallback = async () => { }; export const detachDownloadCallback = async (downloadId: number | undefined = undefined) => { - if (downloadId !== undefined) callbackMap.delete(downloadId); - if (callbackMap.size === 0) { + if (downloadId !== undefined) requestMap.delete(downloadId); + if (requestMap.size === 0) { // 没有待跟踪下载时及时卸载监听,减少后台常驻逻辑和误处理其他下载的风险。 chrome.downloads.onDeterminingFilename.removeListener(onDeterminingFilename); chrome.downloads.onChanged.removeListener(onChangedListener); @@ -134,7 +172,7 @@ export const startDownload = async ( callback: ((o: DownloadCallback) => any) | null = null ) => { let mDownloadId: number | undefined = undefined; - if (callbackMap.size === 0) { + if (requestMap.size === 0) { attachDownloadCallback(); } try { @@ -143,7 +181,7 @@ export const startDownload = async ( // 因此拿到 id 后立即登记,后续事件才能找到对应的回调和文件名覆盖信息。 const id = await chrome.downloads.download(downloadOptions); id && - callbackMap.set(id, { + requestMap.set(id, { callback, nameOverride: downloadOptions.filename && typeof downloadOptions.filename === "string" diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 2fe9b88f4..2c7da2a75 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1252,22 +1252,30 @@ export default class GMApi { const fileName = cleanFileName(params.name); // blob本地文件或显示指定downloadMode为"browser"则直接下载 const blobURL = params.url; - const respond = null; const downloadCallback = (o: DownloadCallback) => { if (o.state === "complete") { if (!isConnDisconnected && !reqCompleteWith) { reqCompleteWith = "ok"; msgConn.sendMessage({ action: "onload", - data: respond, + data: { loaded: o.loaded, total: o.total, mode: "native" }, // compatible with GM.download in TM + }); + } + } else if (o.state === "save_cancelled") { + if (!isConnDisconnected && !reqCompleteWith) { + reqCompleteWith = "save_cancelled"; + msgConn.sendMessage({ + action: "save_cancelled", + data: { loaded: o.loaded, total: o.total, mode: "native" }, // compatible with GM.download in TM }); } } else if (o.state === "interrupted") { if (!isConnDisconnected && !reqCompleteWith) { reqCompleteWith = "interrupted"; + // 这情况须进一步确认 TM 的 GM.download 回传值 msgConn.sendMessage({ action: "onerror", - data: respond, + data: null, }); } } @@ -1289,9 +1297,10 @@ export default class GMApi { if (!blobURL) { if (!isConnDisconnected && !reqCompleteWith) { reqCompleteWith = "error:no_blob_url"; + // 这情况须进一步确认 TM 的 GM.download 回传值 msgConn.sendMessage({ action: "onerror", - data: respond, + data: null, }); } throw new Error("GM_download ERROR: blobURL is not provided."); @@ -1312,9 +1321,10 @@ export default class GMApi { if (cDownloadId === undefined) { if (!isConnDisconnected && !reqCompleteWith) { reqCompleteWith = "error:download_api_error"; + // 这情况须进一步确认 TM 的 GM.download 回传值 msgConn.sendMessage({ action: "onerror", - data: respond, + data: null, }); } } From 7f2c035cf8daa26db919cb0d6c728c3ce1fb0343 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 10 May 2026 14:50:53 +0900 Subject: [PATCH 3/7] typo --- src/app/service/service_worker/download.test.ts | 4 ++-- src/app/service/service_worker/download.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/service/service_worker/download.test.ts b/src/app/service/service_worker/download.test.ts index 256093c49..d54328878 100644 --- a/src/app/service/service_worker/download.test.ts +++ b/src/app/service/service_worker/download.test.ts @@ -95,7 +95,7 @@ describe("startDownload", () => { expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith( expect.objectContaining({ - donwloadId: id, + downloadId: id, state: "complete", }) ); @@ -150,7 +150,7 @@ describe("startDownload", () => { expect(callback).toHaveBeenCalledWith( expect.objectContaining({ - donwloadId: id, + downloadId: id, state: expect.stringMatching(/interrupted|save_cancelled/), // 仅模拟错误 }) ); diff --git a/src/app/service/service_worker/download.ts b/src/app/service/service_worker/download.ts index 7b19c77f2..86c5d573f 100644 --- a/src/app/service/service_worker/download.ts +++ b/src/app/service/service_worker/download.ts @@ -87,7 +87,7 @@ const STATE = { type STATE = ValueOf; export type DownloadCallback = { - donwloadId: number; + downloadId: number; state: STATE | "save_cancelled"; loaded?: number; total?: number; @@ -137,7 +137,7 @@ const onChangedListener = (downloadDelta: chrome.downloads.DownloadDelta) => { const downloadItem = responseMap.get(id); await notifyDownloadCallback(entry.callback, { - donwloadId: id, + downloadId: id, state: state === "interrupted" && filenameConfirmed ? "save_cancelled" : state, loaded: downloadItem?.downloadItem?.totalBytes, // 兼容 TM,总是传回 totalBytes (与实际有否储存无关) total: downloadItem?.downloadItem?.totalBytes, From 54eec01e4e90779f7cc342c0071ba12dcffd281f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 10 May 2026 14:53:39 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E6=B8=85=E9=99=A4=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/download.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/service/service_worker/download.ts b/src/app/service/service_worker/download.ts index 86c5d573f..e73b34b58 100644 --- a/src/app/service/service_worker/download.ts +++ b/src/app/service/service_worker/download.ts @@ -142,6 +142,8 @@ const onChangedListener = (downloadDelta: chrome.downloads.DownloadDelta) => { loaded: downloadItem?.downloadItem?.totalBytes, // 兼容 TM,总是传回 totalBytes (与实际有否储存无关) total: downloadItem?.downloadItem?.totalBytes, }); + + responseMap.delete(id); // 清除缓存 } }); }; From d64cd41ffbc961e60ed10c0b60a1b00033260f64 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 11 May 2026 08:30:32 +0900 Subject: [PATCH 5/7] code update for Copilot comments --- src/app/service/content/gm_api/gm_api.ts | 123 +++++++++++++-------- src/app/service/service_worker/download.ts | 56 +++++++--- 2 files changed, 117 insertions(+), 62 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 4e959b4c4..0c5281435 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1044,9 +1044,25 @@ export default class GMApi extends GM_Base { onloadend: async (res) => { if (aborted) return; const response = res.response; - if (response instanceof Blob) { - const url = URL.createObjectURL(response); // 生命周期跟随当前 content/page 而非 offscreen - const con = await a.connect("GM_download", [ + if (!(response instanceof Blob)) return; + + // 1. 先创建 blob URL,并立即就地准备好释放函数 + 标志位。 + // 这样后续任何抛错/aborted/disconnect 路径都能复用同一处释放逻辑, + // 避免 a.connect 失败或 aborted 短路时 URL 永远不被 revoke。 + const url = URL.createObjectURL(response); // 生命周期跟随当前 content/page 而非 offscreen + let released = false; + const releaseResources = () => { + if (released) return; + released = true; + setTimeout(() => { + // 释放不需要的 URL + URL.revokeObjectURL(url); + }, 1); + }; + + let con: MessageConnect; + try { + con = await a.connect("GM_download", [ { method: details.method, downloadMode: "browser", @@ -1060,49 +1076,66 @@ export default class GMApi extends GM_Base { anonymous: details.anonymous, } as GMTypes.DownloadDetails, ]); - if (aborted) return; - let released = false; - const releaseResources = () => { - if (released) return; - released = true; - setTimeout(() => { - // 释放不需要的 URL - URL.revokeObjectURL(url); - }, 1); - }; - 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 - 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; - } - }); + } catch (e) { + // 后台连接失败:释放 URL,并通过 onerror / reject 通知调用方, + // 行为与 “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")); + } + return; + } + + // a.connect 期间可能已被 abort:立即释放 URL,并关闭多余的连接。 + if (aborted) { + releaseResources(); + try { + con.disconnect(); + } catch { + // ignored + } + return; } + + 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 + 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; + } + }); + + // 后台主动断连(例如 SW 重启、扩展更新)也释放 URL,避免长尾泄漏。 + // releaseResources 通过 released 标志位幂等,与 onMessage 内部的释放调用顺序无关。 + connect.onDisconnect(() => { + releaseResources(); + }); }, onload: () => { // details.onload?.(makeCallbackParam({})) diff --git a/src/app/service/service_worker/download.ts b/src/app/service/service_worker/download.ts index e73b34b58..c9532ad63 100644 --- a/src/app/service/service_worker/download.ts +++ b/src/app/service/service_worker/download.ts @@ -2,6 +2,7 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; type FilenameConflictAction = `${chrome.downloads.FilenameConflictAction}`; type DownloadOptions = chrome.downloads.DownloadOptions; +type InterruptReason = `${chrome.downloads.InterruptReason}`; // https://developer.chrome.com/docs/extensions/reference/api/downloads?hl=en#event-onDeterminingFilename const onDeterminingFilename = ( @@ -115,30 +116,42 @@ const onChangedListener = (downloadDelta: chrome.downloads.DownloadDelta) => { const state = downloadDelta.state?.current; if (state === STATE.COMPLETE || state === STATE.INTERRUPTED) { detachDownloadCallback(id); - let filenameConfirmed = false; - if (!responseMap.has(id)) { - // onDeterminingFilename did not triggered. Fallback to chrome.downloads.search + // 查询最终的 DownloadItem,以便: + // 1) 在 responseMap 尚未填充时补齐 totalBytes/fileSize/bytesReceived; + // 2) 拿到权威的 error 字段,用于区分用户取消(saveAs / 主动取消)与其他中断。 + // 仅当本次 delta 携带 error 信息或 responseMap 未填充时才发起 search, + // 避免对每个 delta 都额外调用 chrome.downloads.search 造成不必要开销。 + let interruptError: InterruptReason | undefined = downloadDelta.error?.current as InterruptReason | undefined; + const needSearch = !responseMap.has(id) || (state === STATE.INTERRUPTED && interruptError === undefined); + if (needSearch) { const downloadItem = (await chrome.downloads.search({ id: id }))?.[0]; if (downloadItem && downloadItem.id === id) { - responseMap.set(id, { - downloadItem: { - bytesReceived: downloadItem.bytesReceived, - fileSize: downloadItem.fileSize, - totalBytes: downloadItem.totalBytes, - }, - }); + if (!responseMap.has(id)) { + responseMap.set(id, { + downloadItem: { + bytesReceived: downloadItem.bytesReceived, + fileSize: downloadItem.fileSize, + totalBytes: downloadItem.totalBytes, + }, + }); + } + if (state === STATE.INTERRUPTED && interruptError === undefined && downloadItem.error) { + interruptError = downloadItem.error as InterruptReason; + } } - } else { - // onDeterminingFilename was triggered - filenameConfirmed = true; } const downloadItem = responseMap.get(id); + // save_cancelled 仅在 chrome 报告 USER_CANCELED 时回报。 + // 早期版本用 “interrupted + 已触发过 onDeterminingFilename” 推断,但 + // onDeterminingFilename 对普通下载也会先触发,NETWORK_FAILED 等中断会被误报。 + const isSaveCancelled = state === STATE.INTERRUPTED && interruptError === "USER_CANCELED"; + await notifyDownloadCallback(entry.callback, { downloadId: id, - state: state === "interrupted" && filenameConfirmed ? "save_cancelled" : state, + state: isSaveCancelled ? "save_cancelled" : state, loaded: downloadItem?.downloadItem?.totalBytes, // 兼容 TM,总是传回 totalBytes (与实际有否储存无关) total: downloadItem?.downloadItem?.totalBytes, }); @@ -161,7 +174,10 @@ const attachDownloadCallback = async () => { }; export const detachDownloadCallback = async (downloadId: number | undefined = undefined) => { - if (downloadId !== undefined) requestMap.delete(downloadId); + if (downloadId !== undefined) { + requestMap.delete(downloadId); + responseMap.delete(downloadId); + } if (requestMap.size === 0) { // 没有待跟踪下载时及时卸载监听,减少后台常驻逻辑和误处理其他下载的风险。 chrome.downloads.onDeterminingFilename.removeListener(onDeterminingFilename); @@ -199,9 +215,15 @@ export const startDownload = async ( console.error(e); } if (chrome.runtime.lastError) { - mDownloadId = undefined; console.error("chrome.runtime.lastError in chrome.downloads.download", chrome.runtime.lastError); + // 若 chrome 在拿到 id 后又设置了 lastError(罕见但可能的实现差异), + // 仍需把已登记的 id 从 requestMap/responseMap 中清理,避免监听挂住。 + // detachDownloadCallback(id) 会 delete 对应条目,并在 requestMap 清空时移除全局监听。 + if (mDownloadId !== undefined) { + await detachDownloadCallback(mDownloadId); + } + mDownloadId = undefined; } - if (mDownloadId == undefined) detachDownloadCallback(); + if (mDownloadId === undefined) await detachDownloadCallback(); return mDownloadId; }; From f840499eff02979e5230cb00954fc95fea1ab9c4 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 11 May 2026 08:58:16 +0900 Subject: [PATCH 6/7] add `example/tests/gm_download_test.js` --- example/tests/gm_download_test.js | 1031 +++++++++++++++++++++++++++++ 1 file changed, 1031 insertions(+) create mode 100644 example/tests/gm_download_test.js diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js new file mode 100644 index 000000000..97af55370 --- /dev/null +++ b/example/tests/gm_download_test.js @@ -0,0 +1,1031 @@ +// ==UserScript== +// @name GM_download / GM.download Test Harness +// @namespace tm-gmdl-test +// @version 0.1.0 +// @description Comprehensive in-page tests for GM_download / GM.download — covers downloadMode native/browser, url types (string / blob / Blob obj), callbacks, abort, conflictAction, and edge cases. +// @author you +// @match *://*/*?GM_DOWNLOAD_TEST_SC +// @grant GM_download +// @grant GM.download +// @grant GM_xmlhttpRequest +// @grant GM_setValue +// @grant GM_getValue +// @grant GM_info +// @connect httpbun.com +// @connect raw.githubusercontent.com +// @connect cdn.jsdelivr.net +// @connect nonexistent-domain-abcxyz.test +// @connect ipv4.download.thinkbroadband.com +// @noframes +// ==/UserScript== + +/* + WHAT THIS DOES + -------------- + - Builds an in-page test runner panel for GM_download / GM.download. + - Drives a battery of tests covering options, callbacks, modes, url forms, and edge paths. + - Every download actually writes a file to disk; all files go under a + user-configurable sub-folder (default: "scriptcat-gmdl-tests/") so cleanup + is one rm -rf away. + + WHAT IT COVERS + -------------- + Auto: + ✓ GM_download(url, name) — string form + ✓ GM_download({ ... }) — options-object form + ✓ GM.download({ ... }) — promise form (resolve / reject) + ✓ downloadMode "native" — sc default: SW xhr fetch then chrome.downloads + ✓ downloadMode "browser" — chrome.downloads only, no xhr + ✓ url types: http(s) string, blob: URL, data: URL, Blob object, File object + ✓ conflictAction: uniquify / overwrite + ✓ onprogress / onload / onerror / ontimeout + ✓ abort() before connect, abort() during, abort() after onload + ✓ headers (passed through to xhr in native mode) + ✓ edge: bad url, empty url, 404, blocked host (@connect missing) + + Manual (verdict-driven — human clicks Mark Pass/Fail/Skip; auto-skips after a timeout + so a forgotten test can never hang the runner): + • saveAs: true — dialog appears, user saves + • saveAs: true — user cancels → must NOT trigger onerror (the bug evaluated above) + • cancel from chrome://downloads while in-progress → must NOT silently succeed + • visual content check — open the file, confirm contents match + + HOW TO USE + ---------- + 1. Install in ScriptCat / Tampermonkey. Grant all listed permissions. + 2. Open any page whose URL matches *?GM_DOWNLOAD_TEST_SC + (e.g. https://example.com/?GM_DOWNLOAD_TEST_SC) + 3. The panel appears bottom-right. Click "Run Auto" to start. + 4. Files land in your downloads folder under the prefix shown in the panel. + Click "Set prefix" to change it. Click "Clear log" to reset counts. +*/ + +const enableTool = true; +(function () { + "use strict"; + if (!enableTool) return; + + // ---------- Tiny DOM helper ---------- + function h(tag, props = {}, ...children) { + const el = document.createElement(tag); + Object.entries(props).forEach(([k, v]) => { + if (k === "style" && typeof v === "object") Object.assign(el.style, v); + else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v); + else el[k] = v; + }); + for (const c of children) el.append(c && c.nodeType ? c : document.createTextNode(String(c))); + return el; + } + + function escapeHtml(s) { + return String(s).replace( + /[&<>"']/g, + (m) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[m] + ); + } + + function fmtMs(ms) { + return ms < 1000 ? `${ms | 0}ms` : `${(ms / 1000).toFixed(2)}s`; + } + + // ---------- Settings (persisted) ---------- + // Prefix is the sub-folder under the user's Downloads dir. Trailing slash auto-appended. + function getPrefix() { + let p = ""; + try { + p = (typeof GM_getValue === "function" ? GM_getValue("dl_prefix", "") : "") || ""; + } catch { /* ignore */ } + if (!p) p = "scriptcat-gmdl-tests/"; + if (!p.endsWith("/")) p += "/"; + return p; + } + function setPrefix(p) { + try { + if (typeof GM_setValue === "function") GM_setValue("dl_prefix", p); + } catch { /* ignore */ } + } + + // Each test gets a unique tail so re-runs don't collide unless we explicitly + // want them to (the conflictAction "overwrite" test reuses a fixed name). + const RUN_TAG = Date.now().toString(36) + "-" + Math.floor(Math.random() * 36 ** 4).toString(36).padStart(4, "0"); + function nameFor(label, ext = "bin") { + return getPrefix() + RUN_TAG + "-" + label.replace(/[^a-zA-Z0-9_-]+/g, "_") + "." + ext; + } + + // ---------- A small dataset built once, reused everywhere ---------- + // 1x1 transparent PNG (67 bytes). + const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x03, 0x01, 0x01, 0x00, 0xae, 0xb4, 0xfa, 0x77, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82 + ]); + const PNG_BLOB = new Blob([PNG_BYTES], { type: "image/png" }); + const TEXT_BLOB = new Blob(["hello from GM_download test harness"], { type: "text/plain" }); + // File extends Blob; useful to verify the URL-object branch handles File too. + const TEXT_FILE = new File([TEXT_BLOB], "ignored-by-api.txt", { type: "text/plain" }); + + // httpbun deterministic endpoint — returns N random bytes. + const HB = "https://httpbun.com"; + + // ---------- Panel ---------- + const panel = h( + "div", + { + id: "gmdl-test-panel", + style: { + position: "fixed", bottom: "12px", right: "12px", + width: "520px", maxHeight: "78vh", overflow: "auto", + zIndex: 2147483647, + background: "#111", color: "#f5f5f5", + font: "13px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + borderRadius: "10px", boxShadow: "0 12px 30px rgba(0,0,0,.4)", + border: "1px solid #333", + }, + }, + h( + "div", + { + style: { + position: "sticky", top: 0, background: "#181818", + padding: "10px 12px", borderBottom: "1px solid #333", + display: "flex", alignItems: "center", gap: "8px", + }, + }, + h("div", { style: { flex: "1 1 auto" } }, + h("div", { style: { fontWeight: "600" } }, + `GM_download Test Harness ${(typeof GM_info === "object" && GM_info.script && GM_info.script.version) || ""}` + ), + h("div", { style: { display: "flex", flexDirection: "row", gap: "10px", marginTop: "2px", opacity: .85, flexWrap: "wrap" } }, + h("div", { style: { fontWeight: "400" } }, + `${(typeof GM_info === "object" && GM_info.scriptHandler) || "?"} ${(typeof GM_info === "object" && GM_info.version) || ""}`), + h("div", { id: "counts", style: { marginLeft: "auto" } }, "…") + ) + ), + h("button", { id: "start", style: btnStyle() }, "Run Auto"), + h("button", { id: "clear", style: btnStyle("#444") }, "Clear log") + ), + + h("div", { id: "status", style: { padding: "6px 12px", borderBottom: "1px solid #222", opacity: .9 } }, "Status: idle"), + + // Settings strip. + h("div", { style: { padding: "6px 12px", borderBottom: "1px solid #222", display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap" } }, + h("span", { style: { opacity: .8 } }, "Download prefix:"), + h("code", { id: "prefix", style: { background: "#222", padding: "2px 6px", borderRadius: "4px" } }, getPrefix()), + h("button", { id: "setPrefix", style: btnStyle("#444") }, "Set prefix"), + h("span", { style: { opacity: .6, marginLeft: "auto", fontSize: "11.5px" } }, `RunTag: ${RUN_TAG}`) + ), + + // Manual section. + h("details", + { id: "manualWrap", open: false, style: { padding: "0 12px 8px", borderBottom: "1px solid #222" } }, + h("summary", { style: { padding: "6px 0", cursor: "pointer", userSelect: "none" } }, "Manual tests (require human)"), + h("div", { id: "manualHint", style: { fontSize: "12px", opacity: .75, margin: "4px 0 6px" } }, + "Each manual test waits for your verdict. Read the instructions in the log, perform the action, then click Mark Pass or Mark Fail. Skip ends the test without a verdict." + ), + h("div", { id: "manualButtons", style: { display: "flex", flexWrap: "wrap", gap: "6px", marginTop: "4px" } }) + ), + + // Awaiting bar — shown only while a manual test is in flight. + h("div", { id: "awaitingWrap", style: { padding: "8px 12px", borderBottom: "1px solid #222", display: "none", background: "#1a1408" } }, + h("div", { style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" } }, + h("div", { style: { flex: "1 1 auto" } }, + h("div", { style: { fontWeight: "600", color: "#fbbf24" } }, "⏳ Awaiting your action"), + h("div", { id: "awaitingLabel", style: { fontSize: "12px", opacity: .85, marginTop: "2px" } }, "") + ), + h("div", { id: "awaitingTimer", style: { fontSize: "12px", opacity: .85, fontFamily: "ui-monospace, monospace" } }, ""), + h("button", { id: "awaitingPass", style: btnStyle("#16a34a") }, "✓ Mark Pass"), + h("button", { id: "awaitingFail", style: btnStyle("#dc2626") }, "✗ Mark Fail"), + h("button", { id: "awaitingSkip", style: btnStyle("#475569") }, "Skip") + ) + ), + + // Queue. + h("details", { id: "queueWrap", open: false, style: { padding: "0 12px 6px", borderBottom: "1px solid #222" } }, + h("summary", { style: { padding: "6px 0", cursor: "pointer", userSelect: "none" } }, "Pending auto tests"), + h("div", { + id: "queue", + style: { + fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace", + whiteSpace: "pre-wrap", opacity: .8, + }, + }, "(none)") + ), + + // Live progress for currently running test. + h("div", { id: "progressWrap", style: { padding: "6px 12px", borderBottom: "1px solid #222", display: "none" } }, + h("div", { id: "progressLabel", style: { fontSize: "12px", opacity: .8, marginBottom: "4px" } }, ""), + h("div", { style: { background: "#222", height: "6px", borderRadius: "3px", overflow: "hidden" } }, + h("div", { id: "progressBar", style: { background: "#2a6df1", height: "100%", width: "0%", transition: "width .15s" } }) + ) + ), + + h("div", { id: "log", style: { padding: "10px 12px" } }) + ); + document.documentElement.appendChild(panel); + + function btnStyle(bg) { + return { + background: bg || "#2a6df1", + color: "white", + border: "0", + padding: "6px 10px", + borderRadius: "6px", + cursor: "pointer", + font: "inherit", + }; + } + + const $log = panel.querySelector("#log"); + const $counts = panel.querySelector("#counts"); + const $status = panel.querySelector("#status"); + const $queue = panel.querySelector("#queue"); + const $prefix = panel.querySelector("#prefix"); + const $progressWrap = panel.querySelector("#progressWrap"); + const $progressLabel = panel.querySelector("#progressLabel"); + const $progressBar = panel.querySelector("#progressBar"); + const $manualButtons = panel.querySelector("#manualButtons"); + const $awaitingWrap = panel.querySelector("#awaitingWrap"); + const $awaitingLabel = panel.querySelector("#awaitingLabel"); + const $awaitingTimer = panel.querySelector("#awaitingTimer"); + const $awaitingPass = panel.querySelector("#awaitingPass"); + const $awaitingFail = panel.querySelector("#awaitingFail"); + const $awaitingSkip = panel.querySelector("#awaitingSkip"); + + panel.querySelector("#clear").addEventListener("click", () => { + $log.textContent = ""; + state.pass = state.fail = state.skip = 0; + setCounts(); + setStatus("idle"); + setQueue([]); + hideProgress(); + }); + panel.querySelector("#start").addEventListener("click", () => runAuto()); + panel.querySelector("#setPrefix").addEventListener("click", () => { + const cur = getPrefix(); + const next = prompt("Download sub-folder (under Downloads). Trailing slash optional.", cur); + if (next == null) return; + setPrefix(next.trim() || "scriptcat-gmdl-tests/"); + $prefix.textContent = getPrefix(); + logLine(`Prefix set to ${escapeHtml(getPrefix())}`); + }); + + function logLine(html, cls = "") { + const line = h("div", { style: { padding: "6px 0", borderBottom: "1px dashed #2a2a2a" } }); + line.innerHTML = html; + if (cls) line.className = cls; + $log.prepend(line); + } + + // ---------- Counters & status ---------- + const state = { pass: 0, fail: 0, skip: 0 }; + function setCounts() { + $counts.textContent = `✅ ${state.pass} ❌ ${state.fail} ⏭️ ${state.skip}`; + } + setCounts(); + function setStatus(text) { $status.textContent = `Status: ${text}`; } + function setQueue(items) { + $queue.textContent = items.length ? items.map((t, i) => `${i + 1}. ${t}`).join("\n") : "(none)"; + } + function pass(msg) { state.pass++; setCounts(); logLine(`✅ ${escapeHtml(msg)}`); } + function fail(msg, extra) { + state.fail++; setCounts(); + logLine( + `❌ ${escapeHtml(msg)}${extra ? `
${escapeHtml(extra)}
` : ""}`, + "fail" + ); + } + function skip(msg) { state.skip++; setCounts(); logLine(`⏭️ ${escapeHtml(msg)}`); } + + function showProgress(label) { + $progressWrap.style.display = ""; + $progressLabel.textContent = label; + $progressBar.style.width = "0%"; + } + function updateProgress(loaded, total) { + if (total > 0) { + $progressBar.style.width = Math.min(100, Math.round((loaded / total) * 100)) + "%"; + } else { + // Unknown total — fake an indeterminate bar that creeps up. + const cur = parseFloat($progressBar.style.width) || 0; + $progressBar.style.width = Math.min(95, cur + 5) + "%"; + } + } + function hideProgress() { + $progressWrap.style.display = "none"; + $progressBar.style.width = "0%"; + } + + // ---------- Assertion helpers ---------- + function assertEq(a, b, msg) { + if (a !== b) throw new Error(msg ? `${msg}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}` : `expected ${b}, got ${a}`); + } + function assertTrue(cond, msg) { if (!cond) throw new Error(msg || "assertTrue failed"); } + function withTimeout(p, ms, label) { + return new Promise((resolve, reject) => { + let done = false; + const t = setTimeout(() => { + if (done) return; + done = true; + reject(new Error(`timed out after ${ms}ms: ${label || ""}`)); + }, ms); + p.then((v) => { if (done) return; done = true; clearTimeout(t); resolve(v); }, + (e) => { if (done) return; done = true; clearTimeout(t); reject(e); }); + }); + } + + // ---------- Awaiting bar (manual-test verdict UI) ---------- + // The manual tests can't be "asserted" purely from JS — the contract often is + // "user sees a dialog, picks Cancel, the script doesn't crash". So we hand the + // verdict back to the human via Pass/Fail/Skip buttons. To avoid the runner + // hanging forever if the human disappears, every manual test runs under a + // countdown that auto-skips when it hits zero. + let _verdictResolve = null; + let _verdictTimerId = null; + let _verdictDeadline = 0; + + function showAwaiting(label, deadlineSecs) { + $awaitingLabel.innerHTML = label; // caller controls HTML, we trust it + $awaitingWrap.style.display = ""; + _verdictDeadline = performance.now() + deadlineSecs * 1000; + tickAwaitingTimer(); + if (_verdictTimerId) clearInterval(_verdictTimerId); + _verdictTimerId = setInterval(tickAwaitingTimer, 250); + } + function tickAwaitingTimer() { + const remaining = Math.max(0, Math.ceil((_verdictDeadline - performance.now()) / 1000)); + $awaitingTimer.textContent = `auto-skip in ${remaining}s`; + if (remaining === 0) { + // Time's up — auto-skip so the runner doesn't hang. + resolveVerdict({ verdict: "skip", reason: "timed out waiting for verdict" }); + } + } + function hideAwaiting() { + $awaitingWrap.style.display = "none"; + $awaitingLabel.innerHTML = ""; + $awaitingTimer.textContent = ""; + if (_verdictTimerId) { clearInterval(_verdictTimerId); _verdictTimerId = null; } + } + function resolveVerdict(v) { + if (!_verdictResolve) return; + const r = _verdictResolve; + _verdictResolve = null; + hideAwaiting(); + r(v); + } + $awaitingPass.addEventListener("click", () => resolveVerdict({ verdict: "pass" })); + $awaitingFail.addEventListener("click", () => { + const reason = prompt("Why did this fail? (optional)", "") || "marked failed by user"; + resolveVerdict({ verdict: "fail", reason }); + }); + $awaitingSkip.addEventListener("click", () => resolveVerdict({ verdict: "skip", reason: "skipped by user" })); + + /** + * Wait for the human to give a verdict via the awaiting bar. + * @param {string} promptHtml HTML shown in the awaiting bar (be careful — trusted source). + * @param {number} [deadlineSecs=120] Auto-skip after this many seconds of no input. + * @returns {Promise<{verdict: "pass"|"fail"|"skip", reason?: string}>} + */ + function awaitVerdict(promptHtml, deadlineSecs = 120) { + return new Promise((resolve) => { + _verdictResolve = resolve; + showAwaiting(promptHtml, deadlineSecs); + }); + } + + + // ---------- GM_download wrappers ---------- + + /** + * Call GM_download with the callback-based API and turn it into a promise. + * Resolves on onload (or save_cancelled — TM's behavior), rejects on onerror/ontimeout. + * Captures all progress events. + * + * Returns: { promise, handle, progress[] } + * - promise: { kind: "load"|"save_cancelled", data } / rejection + * - handle.abort(): abort the download + * - progress: array of onprogress callback args, in order received + */ + function gmDownloadCb(details) { + const progress = []; + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + let saveCancelled = false; + const opts = { + ...details, + onprogress(p) { + progress.push(p); + try { details.onprogress && details.onprogress(p); } catch {} + updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1); + }, + onload(data) { + try { details.onload && details.onload(data); } catch {} + resolve({ kind: saveCancelled ? "save_cancelled" : "load", data }); + }, + onerror(err) { + try { details.onerror && details.onerror(err); } catch {} + reject({ kind: "error", err }); + }, + ontimeout(err) { + try { details.ontimeout && details.ontimeout(err); } catch {} + reject({ kind: "timeout", err }); + }, + }; + // GM_download returns { abort } in both TM and SC. + const handle = GM_download(opts); + return { promise, handle, progress, _markSaveCancelled() { saveCancelled = true; } }; + } + + /** + * Call GM.download (promise form). Returns the Promise itself plus the abort handle. + * Also collects onprogress events. + */ + function gmDownloadPromise(details) { + const progress = []; + const opts = { + ...details, + onprogress(p) { + progress.push(p); + try { details.onprogress && details.onprogress(p); } catch {} + updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1); + }, + }; + const ret = GM.download(opts); + return { promise: ret, abort: ret && ret.abort, progress }; + } + + // ---------- The auto test suite ---------- + // Each entry: { name, manual?: boolean, run: async () => void } + const tests = []; + + function autoTest(name, run) { tests.push({ name, manual: false, run }); } + function manualTest(name, run) { tests.push({ name, manual: true, run }); } + + // 1) sanity: APIs exist + autoTest("APIs exist (GM_download / GM.download)", async () => { + assertEq(typeof GM_download, "function", "GM_download must be a function"); + assertTrue(typeof GM !== "undefined" && typeof GM.download === "function", "GM.download must exist"); + }); + + // 2) string-form: GM_download(url, name) — passes through to the options form + autoTest("GM_download(url, name) — string form", async () => { + const name = nameFor("string-form", "txt"); + const blobUrl = URL.createObjectURL(TEXT_BLOB); + try { + const result = await withTimeout(new Promise((resolve, reject) => { + // String form has no callbacks, so we can only check that it does not throw + // synchronously and returns an object with abort(). The actual download is + // observed by the user. + let h; + try { + h = GM_download(blobUrl, name); + } catch (e) { return reject(e); } + assertTrue(h && typeof h.abort === "function", "handle.abort must be a function"); + // Wait briefly to give the SW a chance to dispatch the download. + setTimeout(() => resolve({ handle: h }), 800); + }), 5000, "string-form"); + assertTrue(!!result, "completed"); + } finally { + URL.revokeObjectURL(blobUrl); + } + }); + + // 3) options-form with onload + autoTest("GM_download({...}) — options form, blob: URL", async () => { + const name = nameFor("options-blob-url", "png"); + const blobUrl = URL.createObjectURL(PNG_BLOB); + try { + const { promise } = gmDownloadCb({ + url: blobUrl, + name, + conflictAction: "uniquify", + }); + const r = await withTimeout(promise, 10000, "blob: URL download"); + assertEq(r.kind, "load", "onload should fire"); + } finally { + URL.revokeObjectURL(blobUrl); + } + }); + + // 4) Blob object as url — should be converted to data: URL or blob URL + autoTest("GM_download with Blob object as url", async () => { + const name = nameFor("blob-object", "png"); + const { promise } = gmDownloadCb({ + url: PNG_BLOB, + name, + }); + const r = await withTimeout(promise, 10000, "Blob object download"); + assertEq(r.kind, "load", "onload should fire"); + }); + + // 5) File object as url — File extends Blob, should also work + autoTest("GM_download with File object as url", async () => { + const name = nameFor("file-object", "txt"); + const { promise } = gmDownloadCb({ + url: TEXT_FILE, + name, + }); + const r = await withTimeout(promise, 10000, "File object download"); + assertEq(r.kind, "load", "onload should fire"); + }); + + // 6) data: URL + autoTest("GM_download with data: URL", async () => { + const name = nameFor("data-url", "txt"); + const dataUrl = "data:text/plain;charset=utf-8,hello%20from%20data%20url"; + const { promise } = gmDownloadCb({ + url: dataUrl, + name, + }); + const r = await withTimeout(promise, 10000, "data: URL download"); + assertEq(r.kind, "load", "onload should fire"); + }); + + // 7) GM.download promise form + autoTest("GM.download promise resolves on success", async () => { + const name = nameFor("promise-form", "txt"); + const { promise } = gmDownloadPromise({ + url: URL.createObjectURL(TEXT_BLOB), + name, + }); + const r = await withTimeout(promise, 10000, "GM.download promise"); + assertTrue(r && typeof r === "object", "should resolve to an object"); + }); + + // 8) native mode — uses SW fetch xhr, multiple onprogress events expected + autoTest("downloadMode 'native' — xhr fetch, onprogress fires", async () => { + const name = nameFor("mode-native", "bin"); + const t0 = performance.now(); + const r = await withTimeout(new Promise((resolve, reject) => { + const h = GM_download({ + url: `${HB}/bytes/4096`, + name, + downloadMode: "native", + onprogress(p) { /* captured in wrapper too, but native mode emits >=1 */ }, + onload: resolve, + onerror: reject, + ontimeout: reject, + }); + // Don't keep the handle around — but ensure abort exists. + if (!h || typeof h.abort !== "function") reject(new Error("handle.abort missing")); + }), 20000, "native mode"); + assertTrue(!!r, "onload received"); + // Sanity bound: 4KB shouldn't take 20s on any sane net. + assertTrue(performance.now() - t0 < 20000, "completed in time"); + }); + + // 9) browser mode — chrome.downloads only + autoTest("downloadMode 'browser' — direct chrome.downloads", async () => { + const name = nameFor("mode-browser", "bin"); + const r = await withTimeout(new Promise((resolve, reject) => { + GM_download({ + url: `${HB}/bytes/2048`, + name, + downloadMode: "browser", + onload: resolve, + onerror: reject, + ontimeout: reject, + }); + }), 20000, "browser mode"); + assertTrue(!!r, "onload received"); + }); + + // 10) onprogress structure (native mode) + autoTest("onprogress event shape (native mode)", async () => { + const name = nameFor("progress-shape", "bin"); + const progresses = []; + await withTimeout(new Promise((resolve, reject) => { + GM_download({ + url: `${HB}/bytes/1024`, + name, + downloadMode: "native", + onprogress(p) { progresses.push(p); }, + onload: resolve, + onerror: reject, + }); + }), 20000, "progress shape"); + assertTrue(progresses.length > 0, "got at least one progress event"); + const last = progresses[progresses.length - 1]; + assertTrue("loaded" in last, "progress has loaded"); + assertTrue("total" in last, "progress has total"); + assertTrue(last.mode === "native" || last.mode === "browser", `mode field present, got ${last.mode}`); + }); + + // 11) conflictAction "overwrite" — second download to the same name should replace + autoTest("conflictAction 'overwrite' — second write replaces", async () => { + // Note: we intentionally do NOT include RUN_TAG so the second run targets + // the same path. uniquify would otherwise produce filename(1), filename(2)... + const fixedName = getPrefix() + "overwrite-target.txt"; + const a = await withTimeout(new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(new Blob(["v1"])), + name: fixedName, + conflictAction: "overwrite", + onload: resolve, + onerror: reject, + }); + }), 10000, "overwrite #1"); + assertTrue(!!a, "first write succeeded"); + const b = await withTimeout(new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(new Blob(["v2"])), + name: fixedName, + conflictAction: "overwrite", + onload: resolve, + onerror: reject, + }); + }), 10000, "overwrite #2"); + assertTrue(!!b, "second write succeeded (overwrite)"); + skip(`(visual check) ${fixedName} should now contain "v2"`); + }); + + // 12) conflictAction "uniquify" — second download gets " (1)" suffix + autoTest("conflictAction 'uniquify' — second write gets suffix", async () => { + const fixedName = getPrefix() + "uniquify-target.txt"; + await withTimeout(new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(new Blob(["v1"])), + name: fixedName, + conflictAction: "uniquify", + onload: resolve, + onerror: reject, + }); + }), 10000, "uniquify #1"); + await withTimeout(new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(new Blob(["v2"])), + name: fixedName, + conflictAction: "uniquify", + onload: resolve, + onerror: reject, + }); + }), 10000, "uniquify #2"); + skip(`(visual check) you should see both uniquify-target.txt and uniquify-target (1).txt`); + }); + + // 13) headers honored in native mode — httpbun /headers echoes request headers + autoTest("headers passed to backend xhr (native mode)", async () => { + // We can't easily inspect what hit the server because the response is + // streamed to disk. So instead: ask /headers, which RESPONDS with the headers + // we sent. Then read that file back via fetch from a blob-URL... no, files + // on disk are not addressable. Cheap alternative: just verify the download + // succeeded with custom headers attached and didn't error. + const name = nameFor("headers-passthrough", "json"); + const r = await withTimeout(new Promise((resolve, reject) => { + GM_download({ + url: `${HB}/headers`, + name, + downloadMode: "native", + headers: { "X-Custom-Probe": "scriptcat-gmdl-test" }, + onload: resolve, + onerror: reject, + }); + }), 20000, "headers passthrough"); + assertTrue(!!r, "onload received"); + skip(`(visual check) open ${name} — X-Custom-Probe should be echoed in the body`); + }); + + // 14) abort() immediately — should not produce a file + autoTest("abort() immediately — no onload, no onerror reached", async () => { + const name = nameFor("abort-immediate", "bin"); + let onloadCalled = false, onerrorCalled = false; + const h = GM_download({ + url: `${HB}/bytes/65536`, + name, + downloadMode: "native", + onload() { onloadCalled = true; }, + onerror() { onerrorCalled = true; }, + }); + h.abort(); + // Give the system 1.5s to (not) call any callbacks. + await new Promise((r) => setTimeout(r, 1500)); + assertEq(onloadCalled, false, "onload should not fire after immediate abort"); + // Note: onerror may still fire in some impls — we accept either no-call or a + // generic error. The important contract is: no successful onload. + if (onerrorCalled) { + skip("onerror fired post-abort (implementation choice, not a failure)"); + } + }); + + // 15) abort() after onload — should be a no-op, no exceptions + autoTest("abort() after onload — safe no-op", async () => { + const name = nameFor("abort-after-load", "txt"); + let handle; + await withTimeout(new Promise((resolve, reject) => { + handle = GM_download({ + url: URL.createObjectURL(TEXT_BLOB), + name, + onload: resolve, + onerror: reject, + }); + }), 10000, "abort-after-load"); + try { + handle.abort(); + } catch (e) { + throw new Error("abort() after onload threw: " + e); + } + }); + + // 16) blocked host: missing @connect — should hit onerror (native mode) + autoTest("blocked host (missing @connect) — onerror", async () => { + const name = nameFor("blocked-host", "bin"); + let onloadCalled = false; + let errSeen = null; + await new Promise((resolve) => { + GM_download({ + url: "https://blocked-host-not-in-connect.example/", + name, + downloadMode: "native", + onload() { onloadCalled = true; resolve(); }, + onerror(e) { errSeen = e || true; resolve(); }, + }); + // Safety timeout + setTimeout(resolve, 8000); + }); + assertEq(onloadCalled, false, "onload must NOT fire"); + assertTrue(!!errSeen, "onerror should fire"); + }); + + // 17) bad URL string — onerror or thrown + autoTest("invalid URL — onerror (no crash)", async () => { + const name = nameFor("bad-url", "bin"); + let onloadCalled = false, errSeen = null, threw = null; + try { + await new Promise((resolve) => { + GM_download({ + url: "not-a-real-url://??", + name, + onload() { onloadCalled = true; resolve(); }, + onerror(e) { errSeen = e || true; resolve(); }, + }); + setTimeout(resolve, 4000); + }); + } catch (e) { threw = e; } + assertEq(onloadCalled, false, "onload must NOT fire on bad URL"); + assertTrue(errSeen != null || threw != null, "either onerror fires or it throws"); + }); + + // 18) empty URL — should be rejected + autoTest("empty URL — onerror or thrown", async () => { + const name = nameFor("empty-url", "bin"); + let onloadCalled = false, errSeen = null, threw = null; + try { + await new Promise((resolve) => { + GM_download({ + url: "", + name, + onload() { onloadCalled = true; resolve(); }, + onerror(e) { errSeen = e || true; resolve(); }, + }); + setTimeout(resolve, 3000); + }); + } catch (e) { threw = e; } + assertEq(onloadCalled, false, "onload must NOT fire on empty URL"); + assertTrue(errSeen != null || threw != null, "either onerror fires or it throws"); + }); + + // 19) name with subdirectories — folder is created under Downloads/ + autoTest("name with subdirectories — nested folder created", async () => { + const name = nameFor("nested/a/b/file", "txt"); + const r = await withTimeout(new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(TEXT_BLOB), + name, + onload: resolve, + onerror: reject, + }); + }), 10000, "nested name"); + assertTrue(!!r, "onload received"); + skip(`(visual check) ${name} should exist with nested folders`); + }); + + // 20) windows-illegal chars in name — should be sanitized (replaced with '-') + autoTest("name with illegal characters — sanitized, no crash", async () => { + // backend cleanFileName() replaces these. We don't know the exact + // replacement but at least the download must succeed. + const rawName = getPrefix() + RUN_TAG + "-illegal<>:\"|?*-chars.txt"; + const r = await withTimeout(new Promise((resolve, reject) => { + GM_download({ + url: URL.createObjectURL(TEXT_BLOB), + name: rawName, + onload: resolve, + onerror: reject, + }); + }), 10000, "illegal chars"); + assertTrue(!!r, "onload received — name was sanitized"); + }); + + // 21) GM.download rejection — invalid URL should reject the promise + autoTest("GM.download promise rejects on invalid URL", async () => { + let rejected = null, resolved = null; + try { + const p = GM.download({ + url: "https://blocked-host-not-in-connect-2.example/", + name: nameFor("promise-reject", "bin"), + }); + // Race with timeout + await withTimeout(p.then(v => { resolved = v; }, e => { rejected = e; }), 8000, "promise-reject"); + } catch (e) { + // withTimeout firing is acceptable too — counts as "did not resolve" + rejected = e; + } + assertTrue(resolved == null, "should not resolve"); + assertTrue(rejected != null, "should reject (or at least not resolve)"); + }); + + // ---------- Manual tests (verdict-driven) ---------- + // + // Each manual test: + // 1. Logs a clear "what to do" and "what to expect" line. + // 2. Kicks off the download. + // 3. Calls awaitVerdict(...) — the human reads the log, performs the action, + // then clicks Mark Pass / Mark Fail / Skip in the awaiting bar. + // 4. Records any callback events as they come in so the verdict isn't blind. + // 5. Has an auto-skip timeout so a forgotten test can't wedge the runner. + // + // We avoid plain `new Promise()` here exactly because the previous version + // could hang forever if neither onload nor onerror was called. + + manualTest("saveAs: true — save dialog appears and saves", async () => { + const name = nameFor("manual-saveAs", "txt"); + logLine(`▶ Manual #1: ${escapeHtml(name)}`); + logLine(`→ Expected: a Save As dialog appears. Pick a location and confirm.`); + logLine(`→ After the file lands, click Mark Pass. If no dialog shows, click Mark Fail.`); + const events = []; + const blobUrl = URL.createObjectURL(TEXT_BLOB); + GM_download({ + url: blobUrl, + name, + saveAs: true, + onload: (d) => { events.push(["onload", d]); logLine(`→ event: onload ${JSON.stringify(d)}`); }, + onerror: (e) => { events.push(["onerror", e]); logLine(`→ event: onerror ${JSON.stringify(e)}`); }, + onprogress: (p) => events.push(["onprogress", p]), + }); + const v = await awaitVerdict("Save the file when the dialog appears, then click Mark Pass.", 180); + URL.revokeObjectURL(blobUrl); + if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (events: ${events.map((e) => e[0]).join(", ") || "none"})`); + if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (events: ${events.map((e) => e[0]).join(", ") || "none"})`); + // Pass — but if zero callbacks fired we want the human to see that too. + if (events.length === 0) logLine(`note: no callbacks fired — Pass accepted but worth checking`); + }); + + manualTest("Cancel saveAs dialog — must NOT be onerror", async () => { + const name = nameFor("manual-saveAs-cancel", "txt"); + logLine(`▶ Manual #2: ${escapeHtml(name)}`); + logLine(`→ Expected: a Save As dialog appears. Click Cancel.`); + logLine(`→ The contract: onload may fire (compat layer maps save_cancelled → onload),`); + logLine(`   but onerror MUST NOT fire.`); + let sawOnerror = false, sawOnload = false; + const events = []; + const blobUrl = URL.createObjectURL(TEXT_BLOB); + GM_download({ + url: blobUrl, + name, + saveAs: true, + onload: (d) => { sawOnload = true; events.push("onload"); logLine(`→ event: onload ${JSON.stringify(d)}`); }, + onerror: (e) => { sawOnerror = true; events.push("onerror"); logLine(`→ event: onerror ${JSON.stringify(e)}`); }, + }); + const v = await awaitVerdict( + "When the Save As dialog appears, click Cancel. Watch the log: if you see onerror, click Mark Fail; otherwise Mark Pass.", + 180 + ); + URL.revokeObjectURL(blobUrl); + if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (sawOnload=${sawOnload}, sawOnerror=${sawOnerror})`); + if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (sawOnload=${sawOnload}, sawOnerror=${sawOnerror})`); + // Verdict was pass — sanity-check it against what we actually observed. + if (sawOnerror) throw new Error("you marked Pass but onerror fired — that's the regression this test guards against"); + if (!sawOnload && !sawOnerror) logLine(`note: neither onload nor onerror fired — implementation may swallow the cancel silently`); + }); + + manualTest("Cancel via chrome://downloads while in-progress — must NOT silently succeed", async () => { + const name = nameFor("manual-cancel-inprogress", "bin"); + // 64 MiB through httpbun gives ~10+ seconds even on a fast connection. + const url = `http://ipv4.download.thinkbroadband.com/100MB.zip?t=${Date.now()}`; + logLine(`▶ Manual #3: ${escapeHtml(name)}`); + logLine(`→ A 64 MiB download is about to start. Open chrome://downloads in a new tab.`); + logLine(`→ When you see it appear there, click Cancel on the entry.`); + logLine(`→ Expected: an onerror callback (or no callback at all) — NOT an onload "success".`); + let sawOnload = false, sawOnerror = false, lastProgress = null; + GM_download({ + url, + name, + downloadMode: "native", + onprogress: (p) => { lastProgress = p; updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1); }, + onload: (d) => { sawOnload = true; logLine(`→ event: onload ${JSON.stringify(d)}`); }, + onerror: (e) => { sawOnerror = true; logLine(`→ event: onerror ${JSON.stringify(e)}`); }, + }); + showProgress("downloading 64 MiB (cancel me)"); + const v = await awaitVerdict( + "Open chrome://downloads, click Cancel on the entry, then Mark Pass. (Mark Fail if onload fired anyway.)", + 300 + ); + hideProgress(); + if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (sawOnload=${sawOnload}, sawOnerror=${sawOnerror}, progress=${lastProgress ? `${lastProgress.loaded}/${lastProgress.total}` : "none"})`); + if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (sawOnload=${sawOnload}, sawOnerror=${sawOnerror})`); + if (sawOnload) throw new Error("you marked Pass but onload fired — cancel was not honored"); + }); + + manualTest("Verify last download wrote a real file (visual check)", async () => { + const name = nameFor("manual-visual-check", "txt"); + const content = `hello at ${new Date().toISOString()} - tag ${RUN_TAG}`; + const blobUrl = URL.createObjectURL(new Blob([content], { type: "text/plain" })); + logLine(`▶ Manual #4: a tiny file is being written.`); + logLine(`→ Expected: open ${escapeHtml(name)} in your Downloads folder.`); + logLine(`→ It must contain this exact text: ${escapeHtml(content)}`); + let landed = false; + GM_download({ + url: blobUrl, + name, + onload: () => { landed = true; logLine("→ event: onload — file should be on disk now."); }, + onerror: (e) => { logLine(`→ event: onerror ${JSON.stringify(e)}`); }, + }); + const v = await awaitVerdict(`Open ${escapeHtml(name)} and confirm the contents match.`, 240); + URL.revokeObjectURL(blobUrl); + if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (landed=${landed})`); + if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (landed=${landed})`); + if (!landed) logLine(`note: marked Pass but onload didn't fire — file presence is the source of truth here`); + }); + + // ---------- Runner ---------- + async function runOne(t, idx, total) { + setStatus(`running (${idx + 1}/${total}): ${t.name}`); + if (!t.manual) showProgress(t.name); + const title = `• ${t.name}`; + const t0 = performance.now(); + try { + logLine(`▶️ ${escapeHtml(t.name)}`); + await t.run(); + pass(`${title} (${fmtMs(performance.now() - t0)})`); + } catch (e) { + const extra = e && e.stack ? e.stack : String(e); + const msg = String(e && e.message || e); + if (msg.startsWith("SKIP:")) { + // Soft outcome — count as skip, not as fail. + skip(`${title} (${fmtMs(performance.now() - t0)}) — ${msg.slice(5).trim()}`); + } else { + fail(`${title} (${fmtMs(performance.now() - t0)})`, extra); + } + } finally { + hideProgress(); + // Any leftover awaiting bar from a thrown-mid-flight test gets cleaned up. + hideAwaiting(); + } + } + + let running = false; + function setAllButtonsDisabled(disabled) { + panel.querySelector("#start").disabled = disabled; + $manualButtons.querySelectorAll("button").forEach((b) => { b.disabled = disabled; b.style.opacity = disabled ? "0.5" : "1"; }); + } + + async function runAuto() { + if (running) { logLine("Already running — wait for the current suite to finish."); return; } + running = true; + setAllButtonsDisabled(true); + try { + const auto = tests.filter((t) => !t.manual); + const names = auto.map((t) => t.name); + setQueue(names.slice()); + logLine(`Starting GM_download auto suite — ${new Date().toLocaleString()} — runTag=${RUN_TAG}`); + logLine(`Files will appear under ${escapeHtml(getPrefix())} with prefix ${escapeHtml(RUN_TAG)}-`); + for (let i = 0; i < auto.length; i++) { + await runOne(auto[i], i, auto.length); + setQueue(names.slice(i + 1)); + } + setStatus("done"); + logLine(`Done. Summary — ✅ ${state.pass} ❌ ${state.fail} ⏭️ ${state.skip}`); + } finally { + running = false; + setAllButtonsDisabled(false); + } + } + + // Build manual buttons. + tests.filter((t) => t.manual).forEach((t) => { + const b = h("button", + { style: btnStyle("#7c3aed"), + onclick: async () => { + if (running) { logLine("Another test is already running — wait for it to finish."); return; } + running = true; + setAllButtonsDisabled(true); + try { await runOne(t, 0, 1); } + finally { + running = false; + setAllButtonsDisabled(false); + setStatus("idle"); + } + } }, + t.name); + $manualButtons.appendChild(b); + }); + + // ---------- Boot ---------- + logLine(`GM_download Test Harness ready. Click Run Auto to run the auto suite, or open Manual tests for human-in-the-loop cases.`); + logLine(`Manual tests use a verdict bar: read the instructions, do the action, then click Mark Pass / Mark Fail / Skip. A countdown auto-skips if you walk away.`); + logLine(`Tip: change the prefix above if you want files in a different sub-folder.`); + setStatus("idle"); + + // No auto-start: GM_download writes to disk, so we wait for explicit user action. +})(); \ No newline at end of file From fb25aa37a7ad5f8883401a7f9af1fa0660c5f0ca Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 11 May 2026 12:46:32 +0900 Subject: [PATCH 7/7] Update gm_download_test.js --- example/tests/gm_download_test.js | 137 +++++++++++++++++++++++++----- 1 file changed, 116 insertions(+), 21 deletions(-) diff --git a/example/tests/gm_download_test.js b/example/tests/gm_download_test.js index 97af55370..8814de5ca 100644 --- a/example/tests/gm_download_test.js +++ b/example/tests/gm_download_test.js @@ -1,7 +1,7 @@ // ==UserScript== // @name GM_download / GM.download Test Harness // @namespace tm-gmdl-test -// @version 0.1.0 +// @version 0.2.0 // @description Comprehensive in-page tests for GM_download / GM.download — covers downloadMode native/browser, url types (string / blob / Blob obj), callbacks, abort, conflictAction, and edge cases. // @author you // @match *://*/*?GM_DOWNLOAD_TEST_SC @@ -14,8 +14,8 @@ // @connect httpbun.com // @connect raw.githubusercontent.com // @connect cdn.jsdelivr.net -// @connect nonexistent-domain-abcxyz.test // @connect ipv4.download.thinkbroadband.com +// @connect nonexistent-domain-abcxyz.test // @noframes // ==/UserScript== @@ -47,9 +47,18 @@ so a forgotten test can never hang the runner): • saveAs: true — dialog appears, user saves • saveAs: true — user cancels → must NOT trigger onerror (the bug evaluated above) - • cancel from chrome://downloads while in-progress → must NOT silently succeed + • native + handle.abort() while downloading → no onload, no onerror after abort + • browser mode + cancel from chrome://downloads → must arrive as onload (save_cancelled), + NOT onerror (this is the regression the download.ts fix guards against) • visual content check — open the file, confirm contents match + Why two cancel tests, not one? + In `downloadMode: "native"` the Service Worker fetches the whole file via xhr + BEFORE chrome.downloads sees anything, so chrome://downloads never shows a + real in-progress entry — you can't cancel it from there in time. So: + - native mode → test handle.abort() instead + - browser mode → test the chrome://downloads Cancel button (real timing) + HOW TO USE ---------- 1. Install in ScriptCat / Tampermonkey. Grant all listed permissions. @@ -196,6 +205,8 @@ const enableTool = true; h("div", { id: "awaitingLabel", style: { fontSize: "12px", opacity: .85, marginTop: "2px" } }, "") ), h("div", { id: "awaitingTimer", style: { fontSize: "12px", opacity: .85, fontFamily: "ui-monospace, monospace" } }, ""), + // Optional in-flight action button (e.g. "🛑 Abort download"); tests register a handler via showAwaitingAction(). + h("button", { id: "awaitingAction", style: { ...btnStyle("#0ea5e9"), display: "none" } }, ""), h("button", { id: "awaitingPass", style: btnStyle("#16a34a") }, "✓ Mark Pass"), h("button", { id: "awaitingFail", style: btnStyle("#dc2626") }, "✗ Mark Fail"), h("button", { id: "awaitingSkip", style: btnStyle("#475569") }, "Skip") @@ -253,6 +264,7 @@ const enableTool = true; const $awaitingPass = panel.querySelector("#awaitingPass"); const $awaitingFail = panel.querySelector("#awaitingFail"); const $awaitingSkip = panel.querySelector("#awaitingSkip"); + const $awaitingAction = panel.querySelector("#awaitingAction"); panel.querySelector("#clear").addEventListener("click", () => { $log.textContent = ""; @@ -367,6 +379,10 @@ const enableTool = true; $awaitingLabel.innerHTML = ""; $awaitingTimer.textContent = ""; if (_verdictTimerId) { clearInterval(_verdictTimerId); _verdictTimerId = null; } + // Tear down any registered action button so it doesn't leak into the next test. + $awaitingAction.style.display = "none"; + $awaitingAction.textContent = ""; + $awaitingAction.onclick = null; } function resolveVerdict(v) { if (!_verdictResolve) return; @@ -395,6 +411,22 @@ const enableTool = true; }); } + /** + * Register an in-flight action button on the awaiting bar. + * Use to expose things like "🛑 Abort download" while we wait for a verdict. + * The button auto-hides when the verdict resolves (or the next showAwaiting() is called). + * @param {string} label Button text. + * @param {() => void} onClick Click handler. Stays attached until the bar hides. + */ + function showAwaitingAction(label, onClick) { + $awaitingAction.textContent = label; + $awaitingAction.style.display = ""; + $awaitingAction.onclick = (ev) => { + ev.preventDefault(); + try { onClick(); } catch (e) { console.error("awaiting action handler threw:", e); } + }; + } + // ---------- GM_download wrappers ---------- @@ -899,32 +931,95 @@ const enableTool = true; if (!sawOnload && !sawOnerror) logLine(`note: neither onload nor onerror fired — implementation may swallow the cancel silently`); }); - manualTest("Cancel via chrome://downloads while in-progress — must NOT silently succeed", async () => { - const name = nameFor("manual-cancel-inprogress", "bin"); - // 64 MiB through httpbun gives ~10+ seconds even on a fast connection. + // Note on cancel testing: + // In `downloadMode: "native"` (the default), SC first fetches the file via + // the Service Worker, only handing it to chrome.downloads at the very end. + // That means chrome://downloads never shows a real in-progress entry — by + // the time it appears, the download is essentially done. You CANNOT cancel + // it from there in time. To test cancel-while-in-progress, we either: + // (a) use `downloadMode: "browser"` so chrome.downloads handles the network + // itself and chrome://downloads gets a real progress bar, or + // (b) call handle.abort() from the test (skips the human entirely on the + // "press Cancel in time" race). + // We cover both. + + manualTest("Abort handle during native download — no onload, no onerror", async () => { + const name = nameFor("manual-abort-native", "bin"); + // 100 MB over plain HTTP, with a cache-buster. const url = `http://ipv4.download.thinkbroadband.com/100MB.zip?t=${Date.now()}`; - logLine(`▶ Manual #3: ${escapeHtml(name)}`); - logLine(`→ A 64 MiB download is about to start. Open chrome://downloads in a new tab.`); - logLine(`→ When you see it appear there, click Cancel on the entry.`); - logLine(`→ Expected: an onerror callback (or no callback at all) — NOT an onload "success".`); - let sawOnload = false, sawOnerror = false, lastProgress = null; - GM_download({ + logLine(`▶ Manual #3a: ${escapeHtml(name)}`); + logLine(`→ A 100 MB download (native mode) starts. Wait until you see onprogress events streaming.`); + logLine(`→ Click the 🛑 Abort download button. Then Mark Pass.`); + logLine(`→ Contract: after abort(), no onload and no onerror should fire.`); + let sawOnload = false, sawOnerror = false, lastProgress = null, abortCalledAt = 0; + const handle = GM_download({ url, name, downloadMode: "native", - onprogress: (p) => { lastProgress = p; updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1); }, - onload: (d) => { sawOnload = true; logLine(`→ event: onload ${JSON.stringify(d)}`); }, - onerror: (e) => { sawOnerror = true; logLine(`→ event: onerror ${JSON.stringify(e)}`); }, + onprogress: (p) => { + lastProgress = p; + updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1); + }, + onload: (d) => { sawOnload = true; logLine(`→ event: onload AFTER ABORT — regression: ${JSON.stringify(d)}`); }, + onerror: (e) => { + // Some implementations DO surface onerror on abort. We log it but don't fail on that alone. + sawOnerror = true; + const sinceAbort = abortCalledAt ? `${(performance.now() - abortCalledAt) | 0}ms after abort()` : "BEFORE abort() — that's a different bug"; + logLine(`→ event: onerror (${sinceAbort}): ${JSON.stringify(e)}`); + }, + }); + showProgress("downloading 100 MB (abort me)"); + showAwaitingAction("🛑 Abort download", () => { + if (abortCalledAt) { logLine("→ abort already requested"); return; } + abortCalledAt = performance.now(); + logLine(`→ calling handle.abort()`); + try { handle.abort(); } catch (e) { logLine(`abort threw: ${escapeHtml(String(e))}`); } }); - showProgress("downloading 64 MiB (cancel me)"); const v = await awaitVerdict( - "Open chrome://downloads, click Cancel on the entry, then Mark Pass. (Mark Fail if onload fired anyway.)", + "Wait for progress events, click 🛑 Abort download, then Mark Pass. (Mark Fail if onload fires after abort.)", 300 ); hideProgress(); - if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (sawOnload=${sawOnload}, sawOnerror=${sawOnerror}, progress=${lastProgress ? `${lastProgress.loaded}/${lastProgress.total}` : "none"})`); - if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (sawOnload=${sawOnload}, sawOnerror=${sawOnerror})`); - if (sawOnload) throw new Error("you marked Pass but onload fired — cancel was not honored"); + const ctx = `aborted=${!!abortCalledAt}, sawOnload=${sawOnload}, sawOnerror=${sawOnerror}, lastProgress=${lastProgress ? `${lastProgress.loaded}/${lastProgress.total}` : "none"}`; + if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (${ctx})`); + if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (${ctx})`); + if (!abortCalledAt) logLine(`note: you marked Pass without clicking Abort — test was inconclusive`); + // The strong invariant: no successful onload after the user asked for abort. + if (sawOnload && abortCalledAt) throw new Error(`onload fired after abort — cancel was not honored (${ctx})`); + }); + + manualTest("Cancel via chrome://downloads (browser mode) — must arrive as onload (save_cancelled), NOT onerror", async () => { + const name = nameFor("manual-cancel-inprogress-browser", "bin"); + // browser mode hands the HTTP fetch to chrome.downloads itself, so the + // entry shows up in chrome://downloads with a real progress bar and a + // working Cancel button. 100 MB on plain HTTP from thinkbroadband gives + // a few seconds of real network time on most connections. + const url = `http://ipv4.download.thinkbroadband.com/100MB.zip?t=${Date.now()}`; + logLine(`▶ Manual #3b: ${escapeHtml(name)}`); + logLine(`→ A 100 MB download (browser mode) starts — chrome://downloads will show it with a real progress bar.`); + logLine(`→ Open chrome://downloads, find the entry, click Cancel.`); + logLine(`→ Contract: SC treats user-cancel as save_cancelled and routes it to onload, NOT onerror.`); + let sawOnload = false, sawOnerror = false, onloadData = null; + GM_download({ + url, + name, + downloadMode: "browser", + onprogress: (p) => updateProgress(p.loaded ?? p.done ?? 0, p.total ?? p.totalSize ?? -1), + onload: (d) => { sawOnload = true; onloadData = d; logLine(`→ event: onload ${JSON.stringify(d)}`); }, + onerror: (e) => { sawOnerror = true; logLine(`→ event: onerror ${JSON.stringify(e)}`); }, + }); + showProgress("downloading 100 MB (cancel from chrome://downloads)"); + const v = await awaitVerdict( + "Cancel the download from chrome://downloads, then Mark Pass if you saw onload (and no onerror).", + 300 + ); + hideProgress(); + const ctx = `sawOnload=${sawOnload}, sawOnerror=${sawOnerror}, onloadData=${JSON.stringify(onloadData)}`; + if (v.verdict === "skip") throw new Error(`SKIP: ${v.reason || "no reason"} (${ctx})`); + if (v.verdict === "fail") throw new Error(`user said FAIL: ${v.reason} (${ctx})`); + // The exact regression this guards: onerror on user-cancel is the bug evaluated above. + if (sawOnerror) throw new Error(`onerror fired on user-cancel — that's the save_cancelled regression (${ctx})`); + if (!sawOnload) logLine(`note: neither onload nor onerror fired — did you actually cancel? Marking Pass anyway because user said so.`); }); manualTest("Verify last download wrote a real file (visual check)", async () => { @@ -1028,4 +1123,4 @@ const enableTool = true; setStatus("idle"); // No auto-start: GM_download writes to disk, so we wait for explicit user action. -})(); \ No newline at end of file +})();