diff --git a/cypress/components/UploadPicker/cancel.cy.ts b/cypress/components/UploadPicker/cancel.cy.ts new file mode 100644 index 00000000..9dcfa57a --- /dev/null +++ b/cypress/components/UploadPicker/cancel.cy.ts @@ -0,0 +1,221 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Folder, Permission } from '@nextcloud/files' +import { generateRemoteUrl } from '@nextcloud/router' +import { UploadPicker, UploadStatus, getUploader } from '../../../lib/index.ts' + +let state: string | undefined + +before(() => { + cy.window().then((win) => { + state = win.document.body.innerHTML + }) +}) + +/** + * Reset the inner body of the document to remove any previous state of the uploader. + */ +function resetDocument(): void { + if (state) { + cy.window().then((win) => { + win.document.body.innerHTML = state! + }) + } +} + +/** + * Throttle the upload speed using browser API. + * @param speed upload speed in bytes per second. -1 for unlimited. + */ +function throttleUpload(speed: number) { + Cypress.automation('remote:debugger:protocol', { + command: 'Network.emulateNetworkConditions', + params: { + offline: speed === 0, + latency: 0, + downloadThroughput: -1, + uploadThroughput: Math.max(speed, -1), + }, + }) +} + +describe('UploadPicker: progress handling', () => { + let dirContent: File[] = [] + + afterEach(() => { + resetDocument() + throttleUpload(-1) + }) + + beforeEach(() => { + dirContent = [] + + // Make sure we reset the destination + // so other tests do not interfere + const propsData = { + content: () => dirContent, + destination: new Folder({ + id: 56, + owner: 'user', + source: generateRemoteUrl('dav/files/user'), + permissions: Permission.ALL, + root: '/files/user', + }), + } + + // Start paused + getUploader(false, true) + .pause() + + // Mount picker + cy.mount(UploadPicker, { + propsData, + }).as('uploadPicker') + + // Check and init aliases + cy.get('[data-cy-upload-picker] [data-cy-upload-picker-input]').as('input').should('exist') + cy.get('[data-cy-upload-picker] [data-cy-upload-picker-progress]').as('progress').should('exist') + cy.get('[data-cy-upload-picker] [data-cy-upload-picker-progress-label]').as('progressLabel').should('exist') + }) + + it('cancels single file upload', () => { + const notify = cy.spy() + getUploader() + .addNotifier(notify) + + cy.get('@input').attachFile({ + // file of 5 MiB + fileContent: new Blob([new ArrayBuffer(5 * 1024 * 1024)]), + fileName: 'file.txt', + mimeType: 'text/plain', + encoding: 'utf8', + lastModified: new Date().getTime(), + }) + + cy.intercept('PUT', '/remote.php/dav/files/user/file.txt', (rq) => { + rq.reply({ statusCode: 201 }) + }).as('upload') + + // 1 MiB/s meaning upload will take 5 seconds + throttleUpload(1024 * 1024) + + // See there is no progress yet + cy.get('@progress') + .should('be.visible') + .should('have.value', 0) + cy.get('@progressLabel') + .should('contain.text', 'paused') + // start the uploader + .then(() => getUploader().start()) + + cy.get('@progress', { timeout: 2000 }) + .should((el) => expect(el.val()).to.be.greaterThan(10)) + .and((el) => expect(el.val()).to.be.lessThan(30)) + + // Now cancel the upload + cy.get('[data-cy-upload-picker-cancel]') + .should('be.visible') + .click() + cy.get('@progress') + .should('not.be.visible') + .then(() => { + // eslint-disable-next-line no-unused-expressions + expect(notify).to.be.calledOnce + expect(notify.getCall(0).args[0].status).to.eq(UploadStatus.CANCELLED) + }) + }) + + it('cancels single chunked file upload', () => { + const notify = cy.spy() + getUploader() + .addNotifier(notify) + + cy.get('@input').attachFile({ + fileContent: new Blob([new ArrayBuffer(15 * 1024 * 1024)]), + fileName: 'file.txt', + mimeType: 'text/plain', + encoding: 'utf8', + lastModified: new Date().getTime(), + }) + + cy.intercept('MKCOL', '/remote.php/dav/uploads/user/*', { statusCode: 201 }) + cy.intercept('DELETE', '/remote.php/dav/uploads/user/*', { statusCode: 201 }) + cy.intercept('PUT', '/remote.php/dav/files/user/file.txt', { statusCode: 201 }) + + // 3 MiB/s meaning upload will take 5 seconds + throttleUpload(3 * 1024 * 1024) + + // See there is no progress yet + cy.get('@progress') + .should('be.visible') + .should('have.value', 0) + cy.get('@progressLabel') + .should('contain.text', 'paused') + // start the uploader + .then(() => getUploader().start()) + + cy.get('@progress', { timeout: 2000 }) + .should((el) => expect(el.val()).to.be.greaterThan(10)) + .and((el) => expect(el.val()).to.be.lessThan(30)) + + // Now cancel the upload + cy.get('[data-cy-upload-picker-cancel]') + .should('be.visible') + .click() + cy.get('@progress') + .should('not.be.visible') + .then(() => { + // eslint-disable-next-line no-unused-expressions + expect(notify).to.be.calledTwice + expect(notify.getCall(0).args[0].status).to.eq(UploadStatus.CANCELLED) + expect(notify.getCall(1).args[0].status).to.eq(UploadStatus.CANCELLED) + }) + }) + + it('cancels single file conflict', () => { + dirContent.push(new File({ owner: 'test', source: 'http://example.com/remote.php/dav/files/test/file.txt', mime: 'text/plain' })) + const notify = cy.spy() + getUploader() + .addNotifier(notify) + + cy.get('@input').attachFile({ + // file of 5 MiB + fileContent: new Blob([new ArrayBuffer(5 * 1024 * 1024)]), + fileName: 'file.txt', + mimeType: 'text/plain', + encoding: 'utf8', + lastModified: new Date().getTime(), + }) + + cy.intercept('PUT', '/remote.php/dav/files/user/file.txt', (rq) => { + rq.reply({ statusCode: 409 }) + }).as('upload') + + // 1 MiB/s meaning upload will take 5 seconds + throttleUpload(1024 * 1024) + + // See there is no progress yet + cy.get('@progress') + .should('be.visible') + .should('have.value', 0) + // start the uploader + .then(() => getUploader().start()) + + cy.get('[role="dialog"]') + .should('be.visible') + .find('[data-cy-conflict-picker-cancel]') + .click() + + cy.contains('.toast-warning', 'Upload has been cancelled').should('be.visible') + + cy.get('@progress') + .should('not.be.visible') + .then(() => { + // eslint-disable-next-line no-unused-expressions + expect(notify).to.be.calledOnce + expect(notify.getCall(0).args[0].status).to.eq(UploadStatus.CANCELLED) + }) + }) +}) diff --git a/lib/components/UploadPicker.vue b/lib/components/UploadPicker.vue index ead99f2e..3210cd15 100644 --- a/lib/components/UploadPicker.vue +++ b/lib/components/UploadPicker.vue @@ -306,7 +306,7 @@ export default defineComponent({ return this.queue?.filter((upload: Upload) => upload.status === UploadStatus.FAILED).length !== 0 }, isUploading(): boolean { - return this.queue?.length > 0 + return this.queue?.filter((upload: Upload) => upload.status !== UploadStatus.CANCELLED).length > 0 }, isAssembling(): boolean { return this.queue?.filter((upload: Upload) => upload.status === UploadStatus.ASSEMBLING).length !== 0 diff --git a/lib/errors/UploadCancelledError.ts b/lib/errors/UploadCancelledError.ts new file mode 100644 index 00000000..8b0ad994 --- /dev/null +++ b/lib/errors/UploadCancelledError.ts @@ -0,0 +1,13 @@ +/*! + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { t } from '../utils/l10n.ts' + +export class UploadCancelledError extends Error { + + public constructor(cause?: unknown) { + super(t('Upload has been cancelled'), { cause }) + } + +} diff --git a/lib/uploader.ts b/lib/uploader.ts index b4bcdf28..fc4cf2a0 100644 --- a/lib/uploader.ts +++ b/lib/uploader.ts @@ -16,6 +16,7 @@ import axios, { isCancel } from '@nextcloud/axios' import PCancelable from 'p-cancelable' import PQueue from 'p-queue' +import { UploadCancelledError } from './errors/UploadCancelledError.ts' import { getChunk, initChunkWorkspace, uploadData } from './utils/upload.js' import { getMaxChunksSize } from './utils/config.js' import { Status as UploadStatus, Upload } from './upload.js' @@ -294,9 +295,15 @@ export class Uploader { upload.status = UploadStatus.FINISHED resolve(uploads) } catch (error) { - logger.error('Error in batch upload', { error }) - upload.status = UploadStatus.FAILED - reject(t('Upload has been cancelled')) + if (isCancel(error) || error instanceof UploadCancelledError) { + logger.info('Upload cancelled by user', { error }) + upload.status = UploadStatus.CANCELLED + reject(new UploadCancelledError(error)) + } else { + logger.error('Error in batch upload', { error }) + upload.status = UploadStatus.FAILED + reject(error) + } } finally { this._notifyAll(upload) this.updateStats() @@ -335,10 +342,14 @@ export class Uploader { await client.createDirectory(folderPath, { signal: abort.signal }) resolve(currentUpload) } catch (error) { - if (error && typeof error === 'object' && 'status' in error && error.status === 405) { + if (isCancel(error) || error instanceof UploadCancelledError) { + currentUpload.status = UploadStatus.CANCELLED + reject(new UploadCancelledError(error)) + } else if (error && typeof error === 'object' && 'status' in error && error.status === 405) { // Directory already exists, so just write into it and ignore the error - currentUpload.status = UploadStatus.FINISHED logger.debug('Directory already exists, writing into it', { directory: directory.name }) + currentUpload.status = UploadStatus.FINISHED + resolve(currentUpload) } else { // Another error happened, so abort uploading the directory currentUpload.status = UploadStatus.FAILED @@ -371,7 +382,7 @@ export class Uploader { const selectedForUpload = await callback(directory.children, folderPath) if (selectedForUpload === false) { logger.debug('Upload canceled by user', { directory }) - reject(t('Upload has been cancelled')) + reject(new UploadCancelledError('Conflict resolution cancelled by user')) return } else if (selectedForUpload.length === 0 && directory.children.length > 0) { logger.debug('Skipping directory, as all files were skipped by user', { directory }) @@ -552,12 +563,12 @@ export class Uploader { logger.debug(`Successfully uploaded ${file.name}`, { file, upload }) resolve(upload) } catch (error) { - if (!isCancel(error)) { - upload.status = UploadStatus.FAILED - reject('Failed assembling the chunks together') + if (isCancel(error) || error instanceof UploadCancelledError) { + upload.status = UploadStatus.CANCELLED + reject(new UploadCancelledError(error)) } else { upload.status = UploadStatus.FAILED - reject(t('Upload has been cancelled')) + reject(t('Failed assembling the chunks together')) } // Cleaning up temp directory @@ -607,9 +618,9 @@ export class Uploader { logger.debug(`Successfully uploaded ${file.name}`, { file, upload }) resolve(upload) } catch (error) { - if (isCancel(error)) { - upload.status = UploadStatus.FAILED - reject(t('Upload has been cancelled')) + if (isCancel(error) || error instanceof UploadCancelledError) { + upload.status = UploadStatus.CANCELLED + reject(new UploadCancelledError(error)) return } @@ -620,7 +631,7 @@ export class Uploader { upload.status = UploadStatus.FAILED logger.error(`Failed uploading ${file.name}`, { error, file, upload }) - reject('Failed uploading the file') + reject(t('Failed uploading the file')) } // Notify listeners of the upload completion