diff --git a/lib/upload/uploader/Uploader.spec.ts b/lib/upload/uploader/Uploader.spec.ts index f9926c40..309dde38 100644 --- a/lib/upload/uploader/Uploader.spec.ts +++ b/lib/upload/uploader/Uploader.spec.ts @@ -60,11 +60,20 @@ vi.mock('./UploadFile.ts', () => ({ removeEventListener() {} dispatchEvent = (() => true) as any dispatchTypedEvent = (() => true) as any - cancel = vi.fn(() => { + + // Mirrors the real `Upload.cancel`: a method that accesses private state, + // so calling it with a foreign `this` throws instead of silently working. + #cancelled = false + get cancelled(): boolean { + return this.#cancelled + } + + cancel() { + this.#cancelled = true if (this.status !== UploadStatus.FINISHED) { this.status = UploadStatus.CANCELLED } - }) + } start = async () => { // simulate progress then finish @@ -107,11 +116,20 @@ vi.mock('./UploadFileTree.ts', () => ({ removeEventListener = (() => {}) as any dispatchEvent = (() => true) as any dispatchTypedEvent = (() => true) as any - cancel = vi.fn(() => { + + // Mirrors the real `Upload.cancel`: a method that accesses private state, + // so calling it with a foreign `this` throws instead of silently working. + #cancelled = false + get cancelled(): boolean { + return this.#cancelled + } + + cancel() { + this.#cancelled = true if (this.status !== UploadStatus.FINISHED) { this.status = UploadStatus.CANCELLED as TUploadStatus } - }) + } initialize = () => [] start = async () => { @@ -250,6 +268,49 @@ describe('Uploader (current API)', () => { expect(finished).toHaveBeenCalled() }) + describe('abort signal', () => { + it('cancels a single upload when the signal is aborted', async () => { + const uploader = new Uploader() + const controller = new AbortController() + + const upload = await uploader.upload('/hello.txt', new File(['a'], 'hello.txt'), { signal: controller.signal }) + expect((upload as unknown as { cancelled: boolean }).cancelled).toBe(false) + + controller.abort() + expect((upload as unknown as { cancelled: boolean }).cancelled).toBe(true) + }) + + it('cancels a batch upload when the signal is aborted', async () => { + const uploader = new Uploader() + const controller = new AbortController() + + const uploads = await uploader.batchUpload('/dir', [new File(['a'], 'a.txt')], { signal: controller.signal }) + const root = uploads.at(-1) as unknown as { cancelled: boolean } + expect(root.cancelled).toBe(false) + + controller.abort() + expect(root.cancelled).toBe(true) + }) + + it('cancels a single upload when the signal is already aborted', async () => { + const uploader = new Uploader() + const controller = new AbortController() + controller.abort() + + const upload = await uploader.upload('/hello.txt', new File(['a'], 'hello.txt'), { signal: controller.signal }) + expect((upload as unknown as { cancelled: boolean }).cancelled).toBe(true) + }) + + it('cancels a batch upload when the signal is already aborted', async () => { + const uploader = new Uploader() + const controller = new AbortController() + controller.abort() + + const uploads = await uploader.batchUpload('/dir', [new File(['a'], 'a.txt')], { signal: controller.signal }) + expect((uploads.at(-1) as unknown as { cancelled: boolean }).cancelled).toBe(true) + }) + }) + describe('upload target resolution', () => { // destination folder source is mocked to https://localhost/remote.php/dav/files/test const defaultRoot = 'https://localhost/remote.php/dav/files/test' diff --git a/lib/upload/uploader/Uploader.ts b/lib/upload/uploader/Uploader.ts index d7db7427..5b3e8ff9 100644 --- a/lib/upload/uploader/Uploader.ts +++ b/lib/upload/uploader/Uploader.ts @@ -326,7 +326,7 @@ export class Uploader extends TypedEventTarget { { ...options, callback, headers }, ) if (options?.signal) { - options.signal.addEventListener('abort', upload.cancel) + this.#attachAbortSignal(options.signal, upload) } const uploads = [...upload.initialize(), upload] @@ -352,7 +352,7 @@ export class Uploader extends TypedEventTarget { const headers = Object.fromEntries(this.#customHeaders.entries()) const upload = new UploadFile(target, fileHandle, { ...options, headers }) if (options?.signal) { - options.signal.addEventListener('abort', upload.cancel) + this.#attachAbortSignal(options.signal, upload) } this.#attachEventListeners(upload) @@ -363,6 +363,20 @@ export class Uploader extends TypedEventTarget { return upload } + /** + * Cancel the upload when the caller provided abort signal is aborted. + * + * @param signal - The abort signal provided by the caller + * @param upload - The upload to cancel when the signal is aborted + */ + #attachAbortSignal(signal: AbortSignal, upload: IUpload): void { + if (signal.aborted) { + upload.cancel() + return + } + signal.addEventListener('abort', () => upload.cancel(), { once: true }) + } + /** * Resolve the absolute upload target for a destination relative to the root folder. *