diff --git a/cypress/components/UploadPicker/UploadPicker.cy.ts b/cypress/components/UploadPicker/UploadPicker.cy.ts index 7b290031..6368da0b 100644 --- a/cypress/components/UploadPicker/UploadPicker.cy.ts +++ b/cypress/components/UploadPicker/UploadPicker.cy.ts @@ -138,9 +138,6 @@ describe('UploadPicker valid uploads', () => { afterEach(() => resetDocument()) it('Uploads a file with chunking', () => { - // Init and reset chunk request spy - const chunksRequestsSpy = cy.spy() - // Intercept tmp upload chunks folder creation cy.intercept('MKCOL', '/remote.php/dav/uploads/*/web-file-upload*', { statusCode: 201, @@ -151,7 +148,6 @@ describe('UploadPicker valid uploads', () => { method: 'PUT', url: '/remote.php/dav/uploads/*/web-file-upload*/*', }, (req) => { - chunksRequestsSpy() req.reply({ statusCode: 201, }) @@ -193,7 +189,7 @@ describe('UploadPicker valid uploads', () => { cy.get('[data-cy-upload-picker] .upload-picker__progress') .as('progress') .should('not.be.visible') - expect(chunksRequestsSpy).to.have.always.been.callCount(26) + cy.get('@chunks.all').should('have.lengthOf', 26) }) }) diff --git a/cypress/components/UploadPicker/status.cy.ts b/cypress/components/UploadPicker/status.cy.ts new file mode 100644 index 00000000..a1ae39ba --- /dev/null +++ b/cypress/components/UploadPicker/status.cy.ts @@ -0,0 +1,143 @@ +/* eslint-disable no-unused-expressions */ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +// dist file might not be built when running eslint only +// eslint-disable-next-line import/no-unresolved,n/no-missing-import +import { Folder, Permission } from '@nextcloud/files' +import { generateRemoteUrl } from '@nextcloud/router' +import { getUploader, UploadPicker } from '../../../lib/index.ts' + +let state: string | undefined +before(() => { + cy.window().then((win) => { + state = win.document.body.innerHTML + }) +}) + +const resetDocument = () => { + if (state) { + cy.window().then((win) => { + win.document.body.innerHTML = state! + }) + } +} + +describe('UploadPicker: status testing', () => { + beforeEach(() => { + // Make sure we reset the destination + // so other tests do not interfere + const propsData = { + destination: new Folder({ + id: 56, + owner: 'user', + source: generateRemoteUrl('dav/files/user'), + permissions: Permission.ALL, + root: '/files/user', + }), + } + + // Mount picker + const onPause = cy.spy().as('pausedListener') + const onResume = cy.spy().as('resumedListener') + cy.mount(UploadPicker, { + propsData, + listeners: { + paused: onPause, + resumed: onResume, + }, + }).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] .upload-picker__progress').as('progress').should('exist') + }) + + afterEach(() => resetDocument()) + + it('shows paused status on pause', () => { + // Intercept tmp upload chunks folder creation + cy.intercept('MKCOL', '/remote.php/dav/uploads/*/web-file-upload*', { + statusCode: 201, + }).as('init') + + // Intercept chunks upload + cy.intercept({ + method: 'PUT', + url: '/remote.php/dav/uploads/*/web-file-upload*/*', + }, (req) => { + req.reply({ + statusCode: 201, + }) + }).as('chunks') + + // Intercept final assembly request + const assemblyStartStub = cy.stub().as('assemblyStart') + cy.intercept('MOVE', '/remote.php/dav/uploads/*/web-file-upload*/.file', (req) => { + assemblyStartStub() + req.reply({ + statusCode: 204, + // Fake assembling chunks + delay: 5000, + }) + }).as('assemblyEnd') + + // Start upload + cy.get('@input').attachFile({ + // Fake file of 256MB + fileContent: new Blob([new ArrayBuffer(256 * 1024 * 1024)]), + fileName: 'photos.zip', + mimeType: 'application/zip', + encoding: 'utf8', + lastModified: new Date().getTime(), + }) + + cy.wait('@init').then(() => { + cy.get('[data-cy-upload-picker] .upload-picker__progress') + .as('progress') + .should('be.visible') + }) + + cy.wait('@chunks').then(() => { + cy.get('[data-cy-upload-picker] .upload-picker__progress') + .as('progress') + .should('be.visible') + cy.get('@progress') + .children('progress') + .should('not.have.value', '0') + cy.get('[data-cy-upload-picker-progress-label]').should('not.contain', 'estimating time left') + cy.get('[data-cy-upload-picker-progress-label]').should('not.contain', 'paused') + + cy.wait(1000).then(() => { + getUploader().pause() + }) + + cy.get('[data-cy-upload-picker-progress-label]').should('contain', 'paused') + cy.get('@pausedListener').should('have.been.calledOnce') + + cy.wait(1000).then(() => { + getUploader().start() + }) + + cy.get('[data-cy-upload-picker-progress-label]').should('not.contain', 'paused') + cy.get('@resumedListener').should('have.been.calledOnce') + }) + + // Should will retry until success or timeout + cy.get('@assemblyStart', { timeout: 30000 }).should('have.been.calledOnce').then(() => { + cy.get('[data-cy-upload-picker] .upload-picker__progress') + .as('progress') + .should('be.visible') + + cy.get('[data-cy-upload-picker-progress-label]').should('not.contain', 'paused') + cy.get('[data-cy-upload-picker-progress-label]').should('contain', 'assembling') + }) + + cy.wait('@assemblyEnd', { timeout: 60000 }).then(() => { + cy.get('[data-cy-upload-picker] .upload-picker__progress') + .as('progress') + .should('not.be.visible') + }) + }) +}) diff --git a/cypress/support/component.ts b/cypress/support/component.ts index 6d04272b..66061a24 100644 --- a/cypress/support/component.ts +++ b/cypress/support/component.ts @@ -29,6 +29,8 @@ import { mount } from '@cypress/vue2' // @ts-expect-error Mock window so this is an internal property window._oc_capabilities = { files: {} } +// @ts-expect-error Mock window so this is an internal property +window._oc_debug = true // Example use: // cy.mount(MyComponent) diff --git a/l10n/messages.pot b/l10n/messages.pot index af7df9cb..2ec66199 100644 --- a/l10n/messages.pot +++ b/l10n/messages.pot @@ -31,6 +31,9 @@ msgstr "" msgid "a few seconds left" msgstr "" +msgid "assembling" +msgstr "" + msgid "Cancel" msgstr "" diff --git a/lib/components/UploadPicker.vue b/lib/components/UploadPicker.vue index bc8621a0..61c9e930 100644 --- a/lib/components/UploadPicker.vue +++ b/lib/components/UploadPicker.vue @@ -105,14 +105,14 @@ -
+
-

- {{ timeLeft }} +

+ {{ status }}

@@ -250,7 +250,7 @@ export default defineComponent({ return { eta: null as null|ReturnType, openedMenu: false, - timeLeft: '', + status: '', newFileMenuEntries: [] as Entry[], uploadManager: getUploader(), @@ -294,16 +294,27 @@ export default defineComponent({ }, hasFailure(): boolean { - return this.queue?.filter((upload: Upload) => upload.status === UploadStatus.FAILED).length !== 0 + return this.queue?.some((upload: Upload) => upload.status === UploadStatus.FAILED) }, isUploading(): boolean { return this.queue?.length > 0 }, - isAssembling(): boolean { - return this.queue?.filter((upload: Upload) => upload.status === UploadStatus.ASSEMBLING).length !== 0 + isOnlyAssembling(): boolean { + return !this.queue?.some((upload: Upload) => { + // ignore empty uploads or meta uploads + if (upload.size === 0) { + return false + } + // If all the uploads are assembling or finished, the ongoing task is assembling + return upload.status !== UploadStatus.ASSEMBLING && upload.status !== UploadStatus.FINISHED + }) }, isPaused(): boolean { - return this.uploadManager.info?.status === Status.PAUSED + return this.uploaderStatus === Status.PAUSED + }, + + uploaderStatus(): Status { + return this.uploadManager.info?.status || Status.IDLE }, buttonLabel(): string { @@ -347,12 +358,17 @@ export default defineComponent({ this.updateStatus() }, - isPaused(isPaused) { - if (isPaused) { + uploaderStatus(status, oldStatus) { + if (status === Status.PAUSED) { this.$emit('paused', this.queue) - } else { + } else if (oldStatus === Status.PAUSED) { this.$emit('resumed', this.queue) } + this.updateStatus() + }, + + isOnlyAssembling() { + this.updateStatus() }, }, @@ -444,28 +460,33 @@ export default defineComponent({ updateStatus() { if (this.isPaused) { - this.timeLeft = t('paused') + this.status = t('paused') + return + } + + if (this.isOnlyAssembling) { + this.status = t('assembling') return } - const estimate = Math.round(this.eta!.estimate()) + const estimate = Math.round(this.eta?.estimate?.() || 0) if (estimate === Infinity) { - this.timeLeft = t('estimating time left') + this.status = t('estimating time left') return } if (estimate < 10) { - this.timeLeft = t('a few seconds left') + this.status = t('a few seconds left') return } if (estimate > 60) { const date = new Date(0) date.setSeconds(estimate) const time = date.toISOString().slice(11, 11 + 8) - this.timeLeft = t('{time} left', { time }) // TRANSLATORS time has the format 00:00:00 + this.status = t('{time} left', { time }) // TRANSLATORS time has the format 00:00:00 return } - this.timeLeft = t('{seconds} seconds left', { seconds: estimate }) + this.status = t('{seconds} seconds left', { seconds: estimate }) }, setDestination(destination: Folder) { diff --git a/lib/uploader.ts b/lib/uploader.ts index 59bfa3ae..61f7c3fe 100644 --- a/lib/uploader.ts +++ b/lib/uploader.ts @@ -174,6 +174,8 @@ export class Uploader { public pause() { this._jobQueue.pause() this._queueStatus = Status.PAUSED + this.updateStats() + logger.debug('Upload paused') } /** @@ -183,6 +185,7 @@ export class Uploader { this._jobQueue.start() this._queueStatus = Status.UPLOADING this.updateStats() + logger.debug('Upload resumed') } /** @@ -512,6 +515,8 @@ export class Uploader { await Promise.all(chunksQueue) this.updateStats() + // Assemble the chunks + upload.status = UploadStatus.ASSEMBLING upload.response = await axios.request({ method: 'MOVE', url: `${tempUrl}/.file`,