diff --git a/dev/upload.html b/dev/upload.html index ac70860b673..600dab22492 100644 --- a/dev/upload.html +++ b/dev/upload.html @@ -62,5 +62,17 @@ +
+

no-auto + max-concurrent-uploads=1

+

Test: Add multiple files, click "Start" on the first file, then try clicking "Start" on another file. The start button should hide and show 0% progress while queued.

+ + diff --git a/packages/upload/README.md b/packages/upload/README.md index b9ea1899ef6..23e5f7c57fd 100644 --- a/packages/upload/README.md +++ b/packages/upload/README.md @@ -28,6 +28,29 @@ Once installed, import the component in your application: import '@vaadin/upload'; ``` +## Performance Considerations + +When uploading large numbers of files, the component automatically throttles concurrent uploads to prevent browser performance degradation. By default, a maximum of 3 files are uploaded simultaneously, with additional files queued automatically. + +You can customize this limit using the `max-concurrent-uploads` attribute: + +```html + + +``` + +```js +// Or set it programmatically +upload.maxConcurrentUploads = 5; +``` + +This helps prevent: +- Browser XHR limitations (failures when uploading 2000+ files simultaneously) +- Performance degradation with hundreds of concurrent uploads +- Network congestion on slower connections + +The default value of 3 balances upload performance with network resource conservation. + ## Contributing Read the [contributing guide](https://vaadin.com/docs/latest/contributing) to learn about our development process, how to propose bugfixes and improvements, and how to test your changes to Vaadin components. diff --git a/packages/upload/src/vaadin-upload-file.js b/packages/upload/src/vaadin-upload-file.js index 8912c83f071..1c273e0ff0a 100644 --- a/packages/upload/src/vaadin-upload-file.js +++ b/packages/upload/src/vaadin-upload-file.js @@ -67,6 +67,9 @@ class UploadFile extends UploadFileMixin(ThemableMixin(PolylitMixin(LumoInjectio /** @protected */ render() { + const isFileStartVisible = this.held && !this.uploading && !this.complete; + const isFileRetryVisible = this.errorMessage; + return html` @@ -83,7 +86,7 @@ class UploadFile extends UploadFileMixin(ThemableMixin(PolylitMixin(LumoInjectio part="start-button" file-event="file-start" @click="${this._fireFileEvent}" - ?hidden="${!this.held}" + ?hidden="${!isFileStartVisible}" ?disabled="${this.disabled}" aria-label="${this.i18n ? this.i18n.file.start : nothing}" aria-describedby="name" @@ -93,7 +96,7 @@ class UploadFile extends UploadFileMixin(ThemableMixin(PolylitMixin(LumoInjectio part="retry-button" file-event="file-retry" @click="${this._fireFileEvent}" - ?hidden="${!this.errorMessage}" + ?hidden="${!isFileRetryVisible}" ?disabled="${this.disabled}" aria-label="${this.i18n ? this.i18n.file.retry : nothing}" aria-describedby="name" diff --git a/packages/upload/src/vaadin-upload-mixin.d.ts b/packages/upload/src/vaadin-upload-mixin.d.ts index 477b0c59d7b..bc9a4b589d6 100644 --- a/packages/upload/src/vaadin-upload-mixin.d.ts +++ b/packages/upload/src/vaadin-upload-mixin.d.ts @@ -205,6 +205,16 @@ export declare class UploadMixinClass { */ uploadFormat: UploadFormat; + /** + * Specifies the maximum number of files that can be uploaded simultaneously. + * This helps prevent browser performance degradation and XHR limitations when + * uploading large numbers of files. Files exceeding this limit will be queued + * and uploaded as active uploads complete. + * @attr {number} max-concurrent-uploads + * @default 3 + */ + maxConcurrentUploads: number; + /** * The object used to localize this component. To change the default * localization, replace this with an object that provides all properties, or diff --git a/packages/upload/src/vaadin-upload-mixin.js b/packages/upload/src/vaadin-upload-mixin.js index bc0b6384c10..55ae6fe1fa6 100644 --- a/packages/upload/src/vaadin-upload-mixin.js +++ b/packages/upload/src/vaadin-upload-mixin.js @@ -322,6 +322,20 @@ export const UploadMixin = (superClass) => value: 'raw', }, + /** + * Specifies the maximum number of files that can be uploaded simultaneously. + * This helps prevent browser performance degradation and XHR limitations when + * uploading large numbers of files. Files exceeding this limit will be queued + * and uploaded as active uploads complete. + * @attr {number} max-concurrent-uploads + * @type {number} + */ + maxConcurrentUploads: { + type: Number, + value: 3, + sync: true, + }, + /** * Pass-through to input's capture attribute. Allows user to trigger device inputs * such as camera or microphone immediately. @@ -347,6 +361,18 @@ export const UploadMixin = (superClass) => _files: { type: Array, }, + + /** @private */ + _uploadQueue: { + type: Array, + value: () => [], + }, + + /** @private */ + _activeUploads: { + type: Number, + value: 0, + }, }; } @@ -694,16 +720,46 @@ export const UploadMixin = (superClass) => if (files && !Array.isArray(files)) { files = [files]; } - files = files.filter((file) => !file.complete); - Array.prototype.forEach.call(files, this._uploadFile.bind(this)); + files.filter((file) => !file.complete).forEach((file) => this._queueFileUpload(file)); } /** @private */ - _uploadFile(file) { + _queueFileUpload(file) { if (file.uploading) { return; } + file.held = true; + file.uploading = file.indeterminate = true; + file.complete = file.abort = file.error = false; + file.status = this.__effectiveI18n.uploading.status.held; + this._renderFileList(); + + this._uploadQueue.push(file); + this._processUploadQueue(); + } + + /** + * Process the upload queue by starting uploads for queued files + * if there is available capacity. + * + * @private + */ + _processUploadQueue() { + // Process as many queued files as we have capacity for + while (this._uploadQueue.length > 0 && this._activeUploads < this.maxConcurrentUploads) { + const nextFile = this._uploadQueue.shift(); + if (nextFile) { + this._uploadFile(nextFile); + } + } + } + + /** @private */ + _uploadFile(file) { + // Increment active uploads counter + this._activeUploads += 1; + const ini = Date.now(); const xhr = (file.xhr = this._createXhr()); @@ -740,11 +796,25 @@ export const UploadMixin = (superClass) => this.dispatchEvent(new CustomEvent('upload-progress', { detail: { file, xhr } })); }; + xhr.onabort = () => { + clearTimeout(stalledId); + file.indeterminate = file.uploading = false; + + // Decrement active uploads counter + this._activeUploads -= 1; + this._processUploadQueue(); + }; + // More reliable than xhr.onload xhr.onreadystatechange = () => { if (xhr.readyState === 4) { clearTimeout(stalledId); file.indeterminate = file.uploading = false; + + // Decrement active uploads counter + this._activeUploads -= 1; + this._processUploadQueue(); + if (file.abort) { return; } @@ -815,9 +885,8 @@ export const UploadMixin = (superClass) => xhr.open(this.method, file.uploadTarget, true); this._configureXhr(xhr, file, isRawUpload); + file.held = false; file.status = this.__effectiveI18n.uploading.status.connecting; - file.uploading = file.indeterminate = true; - file.complete = file.abort = file.error = file.held = false; xhr.upload.onloadstart = () => { this.dispatchEvent( @@ -862,7 +931,7 @@ export const UploadMixin = (superClass) => }), ); if (evt) { - this._uploadFile(file); + this._queueFileUpload(file); this._updateFocus(this.files.indexOf(file)); } } @@ -934,7 +1003,7 @@ export const UploadMixin = (superClass) => this.files = [file, ...this.files]; if (!this.noAuto) { - this._uploadFile(file); + this._queueFileUpload(file); } } @@ -957,6 +1026,9 @@ export const UploadMixin = (superClass) => * @protected */ _removeFile(file) { + this._uploadQueue = this._uploadQueue.filter((f) => f !== file); + this._processUploadQueue(); + const fileIndex = this.files.indexOf(file); if (fileIndex >= 0) { this.files = this.files.filter((i) => i !== file); @@ -998,7 +1070,7 @@ export const UploadMixin = (superClass) => /** @private */ _onFileStart(event) { - this._uploadFile(event.detail.file); + this._queueFileUpload(event.detail.file); } /** @private */ diff --git a/packages/upload/test/adding-files.test.js b/packages/upload/test/adding-files.test.js index 8e2787e1420..1c399477c1d 100644 --- a/packages/upload/test/adding-files.test.js +++ b/packages/upload/test/adding-files.test.js @@ -19,7 +19,7 @@ describe('adding files', () => { beforeEach(async () => { upload = fixtureSync(``); - upload.target = 'http://foo.com/bar'; + upload.target = 'https://foo.com/bar'; upload._createXhr = xhrCreator({ size: testFileSize, uploadTime: 200, stepTime: 50 }); await nextRender(); files = createFiles(2, testFileSize, 'application/x-octet-stream'); @@ -332,12 +332,21 @@ describe('adding files', () => { describe('start upload', () => { it('should automatically start upload', () => { + upload.maxConcurrentUploads = 1; const uploadStartSpy = sinon.spy(); upload.addEventListener('upload-start', uploadStartSpy); files.forEach(upload._addFile.bind(upload)); - expect(uploadStartSpy.calledTwice).to.be.true; - expect(upload.files[0].held).to.be.false; + // With queue behavior, only the first file starts uploading immediately + expect(uploadStartSpy.calledOnce).to.be.true; + + // Files are prepended, so the first file added is at index 1 + expect(upload.files[1].held).to.be.false; + expect(upload.files[1].uploading).to.be.true; + + // Second file (at index 0) should be queued + expect(upload.files[0].held).to.be.true; + expect(upload.files[0].uploading).to.be.true; }); it('should not automatically start upload when noAuto flag is set', () => { @@ -348,6 +357,7 @@ describe('adding files', () => { files.forEach(upload._addFile.bind(upload)); expect(uploadStartSpy.called).to.be.false; expect(upload.files[0].held).to.be.true; + expect(upload.files[0].uploading).to.not.be.true; }); }); diff --git a/packages/upload/test/concurrent-uploads.test.js b/packages/upload/test/concurrent-uploads.test.js new file mode 100644 index 00000000000..8c32d0f0437 --- /dev/null +++ b/packages/upload/test/concurrent-uploads.test.js @@ -0,0 +1,357 @@ +import { expect } from '@vaadin/chai-plugins'; +import { fixtureSync, nextRender } from '@vaadin/testing-helpers'; +import sinon from 'sinon'; +import '../src/vaadin-upload.js'; +import { createFiles, xhrCreator } from './helpers.js'; + +function assertFileUploading(file) { + expect(file.uploading).to.be.true; + expect(file.held).to.be.false; +} + +function assertFileNotStarted(file) { + expect(file.uploading).to.not.be.true; + expect(file.held).to.be.true; + expect(file.status).to.equal('Queued'); +} + +function assertFileQueued(file) { + expect(file.uploading).to.be.true; + expect(file.held).to.be.true; + expect(file.status).to.equal('Queued'); +} + +function assertFileSucceeded(file) { + expect(file.error).to.be.not.ok; + expect(file.complete).to.be.true; + expect(file.uploading).to.be.false; +} + +function assertFileFailed(file) { + expect(file.error).to.be.ok; + expect(file.uploading).to.be.false; +} + +describe('concurrent uploads', () => { + let upload; + + beforeEach(async () => { + upload = fixtureSync(``); + upload.target = 'https://foo.com/bar'; + await nextRender(); + }); + + describe('maxConcurrentUploads property', () => { + it('should have default value of 3', () => { + expect(upload.maxConcurrentUploads).to.equal(3); + }); + + it('should accept custom value', () => { + upload.maxConcurrentUploads = 5; + expect(upload.maxConcurrentUploads).to.equal(5); + }); + + it('should accept Infinity for unlimited uploads', () => { + upload.maxConcurrentUploads = Infinity; + expect(upload.maxConcurrentUploads).to.equal(Infinity); + }); + }); + + describe('upload queue management', () => { + let clock; + + beforeEach(() => { + upload._createXhr = xhrCreator({ size: 100, uploadTime: 200, stepTime: 50 }); + clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should show queued status for files in queue', async () => { + const files = createFiles(5, 100, 'application/json'); + upload.maxConcurrentUploads = 2; + + upload.uploadFiles(files); + await clock.tickAsync(10); + + // First 2 files should be uploading + files.slice(0, 2).forEach((file) => { + expect(file.status).to.not.equal('Queued'); + }); + + // Remaining files should be queued + files.slice(2, -1).forEach((file) => { + expect(file.status).to.equal('Queued'); + }); + }); + + it('should process queue as uploads complete', async () => { + const files = createFiles(5, 100, 'application/json'); + upload.maxConcurrentUploads = 2; + + upload.uploadFiles(files); + await clock.tickAsync(10); + + files.slice(0, 2).forEach(assertFileUploading); + files.slice(2, -1).forEach(assertFileQueued); + // Wait for first uploads to complete + await clock.tickAsync(250); + + files.slice(2, 4).forEach(assertFileUploading); + files.slice(4, -1).forEach(assertFileQueued); + }); + + it('should handle all uploads completing', async () => { + const files = createFiles(5, 100, 'application/json'); + upload.maxConcurrentUploads = 2; + + upload.uploadFiles(files); + + // Wait for all uploads to complete + await clock.tickAsync(1000); + + files.forEach(assertFileSucceeded); + }); + + it('should work with manual upload mode', async () => { + const files = createFiles(5, 100, 'application/json'); + upload.noAuto = true; + upload.maxConcurrentUploads = 2; + + upload._addFiles(files); + await clock.tickAsync(10); + + files.forEach(assertFileNotStarted); + + // Start uploads manually + upload.uploadFiles(files); + await clock.tickAsync(10); + + files.slice(0, 2).forEach(assertFileUploading); + files.slice(2, -1).forEach(assertFileQueued); + }); + }); + + describe('upload queue with abort', () => { + beforeEach(() => { + upload._createXhr = sinon.spy(xhrCreator({ size: 100, uploadTime: 200, stepTime: 50 })); + }); + + it('should remove file from queue when aborted', () => { + const files = createFiles(2, 100, 'application/json'); + upload.maxConcurrentUploads = 1; + + upload.uploadFiles(files); + expect(upload._createXhr).to.be.calledOnce; + + upload._createXhr.resetHistory(); + + // Abort a queued file + upload._abortFileUpload(files[1]); + expect(upload._createXhr).to.be.not.called; + }); + + it('should process queue after aborting an uploading file', () => { + const files = createFiles(2, 100, 'application/json'); + upload.maxConcurrentUploads = 1; + + upload.uploadFiles(files); + expect(upload._createXhr).to.be.calledOnce; + + upload._createXhr.resetHistory(); + + files[0].xhr.abort(); + expect(upload._createXhr).to.be.calledOnce; + }); + }); + + describe('upload queue with errors', () => { + let clock; + + beforeEach(() => { + clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should process queue when upload fails', async () => { + upload._createXhr = xhrCreator({ + size: 100, + uploadTime: 100, + stepTime: 25, + serverTime: 10, + serverValidation: () => ({ status: 500, statusText: 'Error' }), + }); + + const files = createFiles(5, 100, 'application/json'); + upload.maxConcurrentUploads = 2; + + upload.uploadFiles(files); + await clock.tickAsync(10); + + files.slice(0, 2).forEach(assertFileUploading); + files.slice(2, -1).forEach(assertFileQueued); + + // Wait for first 2 uploads to fail (uploadTime + stepTime + serverTime = 100 + 25 + 10 = 135ms) + await clock.tickAsync(150); + + // After first 2 fail, next 2 should start from queue + files.slice(2, -1).forEach(assertFileUploading); + }); + + it('should handle response event cancellation', async () => { + upload._createXhr = xhrCreator({ size: 100, uploadTime: 200, stepTime: 50 }); + + const files = createFiles(5, 100, 'application/json'); + upload.maxConcurrentUploads = 2; + + upload.addEventListener('upload-response', (e) => { + e.preventDefault(); + }); + + upload.uploadFiles(files); + await clock.tickAsync(10); + + files.slice(0, 2).forEach(assertFileUploading); + files.slice(2, -1).forEach(assertFileQueued); + + // Wait for uploads to reach completion state + await clock.tickAsync(250); + + // When response is prevented, files stay in uploading state + // but queue should still be processed once xhr completes + files.slice(2, 4).forEach(assertFileUploading); + files.slice(4, -1).forEach(assertFileQueued); + }); + }); + + describe('unlimited concurrent uploads', () => { + beforeEach(() => { + upload._createXhr = xhrCreator({ size: 100, uploadTime: 200, stepTime: 50 }); + }); + + it('should allow unlimited uploads when maxConcurrentUploads is Infinity', () => { + const files = createFiles(20, 100, 'application/json'); + upload.maxConcurrentUploads = Infinity; + upload.uploadFiles(files); + files.forEach(assertFileUploading); + }); + }); + + describe('dynamic maxConcurrentUploads change', () => { + let clock; + + beforeEach(() => { + upload._createXhr = xhrCreator({ size: 100, uploadTime: 200, stepTime: 50 }); + clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should respect new limit when increased during uploads', async () => { + const files = createFiles(10, 100, 'application/json'); + upload.maxConcurrentUploads = 1; + + upload.uploadFiles(files); + await clock.tickAsync(10); + + files.slice(0, 1).forEach(assertFileUploading); + files.slice(1, -1).forEach(assertFileQueued); + + // Increase limit + upload.maxConcurrentUploads = 10; + await clock.tickAsync(300); + + files.slice(1, -1).forEach(assertFileUploading); + }); + }); + + describe('retry with queue', () => { + let clock; + + beforeEach(() => { + clock = sinon.useFakeTimers({ + shouldClearNativeTimers: true, + }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should handle retry of failed file with queue', async () => { + upload._createXhr = xhrCreator({ + size: 100, + serverValidation: () => ({ status: 500, statusText: 'Error' }), + }); + + const files = createFiles(3, 100, 'application/json'); + upload.maxConcurrentUploads = 2; + + upload.uploadFiles(files); + await clock.tickAsync(10); + + files.slice(0, 2).forEach(assertFileUploading); + + // Wait for uploads to fail + await clock.tickAsync(100); + + files.slice(0, 2).forEach(assertFileFailed); + + // Replace XHR creator with successful one + upload._createXhr = xhrCreator({ size: 100, uploadTime: 200, stepTime: 50 }); + + // Retry first file + upload._retryFileUpload(files[0]); + await clock.tickAsync(10); + + assertFileUploading(files[0]); + assertFileFailed(files[1]); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + upload._createXhr = sinon.spy(xhrCreator({ size: 100, uploadTime: 200, stepTime: 50 })); + }); + + it('should handle single file with limit of 1', () => { + const files = createFiles(1, 100, 'application/json'); + upload.maxConcurrentUploads = 1; + + upload.uploadFiles(files); + expect(upload._createXhr).to.be.calledOnce; + }); + + it('should handle zero files', () => { + upload.maxConcurrentUploads = 5; + expect(upload._createXhr).to.be.not.called; + }); + + it('should not start upload if already uploading', () => { + const files = createFiles(1, 100, 'application/json'); + upload.maxConcurrentUploads = 1; + + upload.uploadFiles(files[0]); + expect(upload._createXhr).to.be.calledOnce; + + upload._createXhr.resetHistory(); + + // Try to upload same file again + upload.uploadFiles(files[0]); + expect(upload._createXhr).to.be.not.called; + }); + }); +}); diff --git a/packages/upload/test/helpers.js b/packages/upload/test/helpers.js index 20fbf8b8f62..06c888199b7 100644 --- a/packages/upload/test/helpers.js +++ b/packages/upload/test/helpers.js @@ -132,6 +132,11 @@ export function xhrCreator(c) { xhr.upload = { onprogress() {}, }; + xhr.abort = function () { + if (xhr.onabort) { + xhr.onabort(); + } + }; xhr.onsend = function () { if (xhr.upload.onloadstart) { xhr.upload.onloadstart(); diff --git a/packages/upload/test/upload.test.js b/packages/upload/test/upload.test.js index 2d6c8567d47..3bc8a7156e4 100644 --- a/packages/upload/test/upload.test.js +++ b/packages/upload/test/upload.test.js @@ -9,7 +9,7 @@ describe('upload', () => { beforeEach(async () => { upload = fixtureSync(``); - upload.target = 'http://foo.com/bar'; + upload.target = 'https://foo.com/bar'; file = createFile(100000, 'application/unknown'); await nextRender(); }); @@ -56,13 +56,13 @@ describe('upload', () => { expect(e.detail.file.uploading).to.be.ok; done(); }); - upload._uploadFile(file); + upload._queueFileUpload(file); }); it('should fire the upload-progress event multiple times', async () => { const spy = sinon.spy(); upload.addEventListener('upload-progress', spy); - upload._uploadFile(file); + upload._queueFileUpload(file); await clock.tickAsync(10); const e = spy.firstCall.args[0]; @@ -95,7 +95,7 @@ describe('upload', () => { it('should fire the upload-success', async () => { const spy = sinon.spy(); upload.addEventListener('upload-success', spy); - upload._uploadFile(file); + upload._queueFileUpload(file); await clock.tickAsync(400); const e = spy.firstCall.args[0]; @@ -111,7 +111,7 @@ describe('upload', () => { const errorSpy = sinon.spy(); upload.addEventListener('upload-error', errorSpy); - upload._uploadFile(file); + upload._queueFileUpload(file); await clock.tickAsync(100); const progressEvt = progressSpy.firstCall.args[0]; @@ -142,7 +142,7 @@ describe('upload', () => { done(); }; }); - upload._uploadFile(file); + upload._queueFileUpload(file); }); it('should not override configurable request url if already set', (done) => { @@ -153,7 +153,7 @@ describe('upload', () => { done(); }); file.uploadTarget = modifiedUrl; - upload._uploadFile(file); + upload._queueFileUpload(file); }); it('should fire the upload-before with configurable form data name in multipart mode', (done) => { @@ -180,7 +180,7 @@ describe('upload', () => { }; }); - upload._uploadFile(file); + upload._queueFileUpload(file); window.FormData = OriginalFormData; }); @@ -194,14 +194,14 @@ describe('upload', () => { }); upload.formDataName = 'attachment'; - upload._uploadFile(file); + upload._queueFileUpload(file); }); it('should not open xhr if `upload-before` event is cancelled', () => { upload.addEventListener('upload-before', (e) => { e.preventDefault(); }); - upload._uploadFile(file); + upload._queueFileUpload(file); expect(file.xhr.readyState).to.equal(0); }); @@ -215,7 +215,7 @@ describe('upload', () => { expect(e.detail.formData).to.be.ok; done(); }); - upload._uploadFile(file); + upload._queueFileUpload(file); }); it('should not send xhr if `upload-request` listener prevents default', (done) => { @@ -228,7 +228,7 @@ describe('upload', () => { }); }); - upload._uploadFile(file); + upload._queueFileUpload(file); }); it('should fail if a `upload-response` listener sets an error', async () => { @@ -240,7 +240,7 @@ describe('upload', () => { const errorSpy = sinon.spy(); upload.addEventListener('upload-error', errorSpy); - upload._uploadFile(file); + upload._queueFileUpload(file); await clock.tickAsync(250); const e = errorSpy.firstCall.args[0]; @@ -254,7 +254,7 @@ describe('upload', () => { e.preventDefault(); }); - upload._uploadFile(file); + upload._queueFileUpload(file); await clock.tickAsync(100); expect(file.uploading).to.be.ok; @@ -278,7 +278,7 @@ describe('upload', () => { expect(e.detail.xhr.withCredentials).to.be.true; done(); }); - upload._uploadFile(file); + upload._queueFileUpload(file); }); }); @@ -306,7 +306,7 @@ describe('upload', () => { const spy = sinon.spy(); upload.addEventListener('upload-error', spy); - upload._uploadFile(file); + upload._queueFileUpload(file); await clock.tickAsync(50); const e = spy.firstCall.args[0]; @@ -348,7 +348,7 @@ describe('upload', () => { }); it('should be indeterminate when connecting', async () => { - upload._uploadFile(file); + upload._queueFileUpload(file); await clock.tickAsync(200); expect(file.indeterminate).to.be.ok; expect(file.status).to.be.equal(upload.i18n.uploading.status.connecting); @@ -357,7 +357,7 @@ describe('upload', () => { it('should not be indeterminate when progressing', async () => { const spy = sinon.spy(); upload.addEventListener('upload-progress', spy); - upload._uploadFile(file); + upload._queueFileUpload(file); await clock.tickAsync(600); const e = spy.firstCall.args[0]; expect(e.detail.file.status).to.contain(upload.i18n.uploading.remainingTime.prefix); @@ -365,7 +365,7 @@ describe('upload', () => { }); it('should be indeterminate when server is processing the file', async () => { - upload._uploadFile(file); + upload._queueFileUpload(file); await clock.tickAsync(800); expect(file.indeterminate).to.be.ok; expect(file.status).to.be.equal(upload.i18n.uploading.status.processing); @@ -390,7 +390,7 @@ describe('upload', () => { }); it('should be stalled when progress is not updated for more than 2 sec.', async () => { - upload._uploadFile(file); + upload._queueFileUpload(file); await clock.tickAsync(2200); expect(file.status).to.be.equal(upload.i18n.uploading.status.stalled); }); @@ -443,16 +443,21 @@ describe('upload', () => { upload.files.forEach((file) => { expect(file.uploading).not.to.be.ok; }); + let firstUploadStartFired = false; upload.addEventListener('upload-start', (e) => { - expect(e.detail.xhr).to.be.ok; - expect(e.detail.file).to.be.ok; - expect(e.detail.file.name).to.equal(tempFileName); - expect(e.detail.file.uploading).to.be.ok; + if (!firstUploadStartFired) { + firstUploadStartFired = true; + expect(e.detail.xhr).to.be.ok; + expect(e.detail.file).to.be.ok; + expect(e.detail.file.name).to.equal(tempFileName); + expect(e.detail.file.uploading).to.be.ok; - for (let i = 0; i < upload.files.length - 1; i++) { - expect(upload.files[i].uploading).not.to.be.ok; + for (let i = 0; i < upload.files.length - 1; i++) { + expect(upload.files[i].uploading).not.to.be.ok; + } + done(); } - done(); + // With queue behavior, other files will start after the first completes - ignore those events }); upload.uploadFiles([upload.files[2]]); }); @@ -563,7 +568,7 @@ describe('upload', () => { expect(e.detail.formData).to.be.instanceOf(FormData); done(); }); - upload._uploadFile(file); + upload._queueFileUpload(file); }); it('should send file directly for raw format', (done) => { @@ -574,7 +579,7 @@ describe('upload', () => { expect(e.detail.formData).to.be.undefined; done(); }); - upload._uploadFile(file); + upload._queueFileUpload(file); }); it('should set Content-Type header to file MIME type in raw format', (done) => { @@ -585,7 +590,7 @@ describe('upload', () => { expect(contentType).to.equal('application/pdf'); done(); }); - upload._uploadFile(pdfFile); + upload._queueFileUpload(pdfFile); }); it('should set X-Filename header in raw format', (done) => { @@ -596,7 +601,7 @@ describe('upload', () => { expect(filename).to.equal(encodeURIComponent(testFile.name)); done(); }); - upload._uploadFile(testFile); + upload._queueFileUpload(testFile); }); it('should encode special characters in X-Filename header in raw format', (done) => { @@ -608,7 +613,7 @@ describe('upload', () => { expect(filename).to.equal('religion%20%C3%A5k4.pdf'); done(); }); - upload._uploadFile(testFile); + upload._queueFileUpload(testFile); }); it('should set Content-Type to application/octet-stream when file has no type in raw format', (done) => { @@ -625,7 +630,7 @@ describe('upload', () => { expect(contentType).to.equal('application/octet-stream'); done(); }); - upload._uploadFile(unknownFile); + upload._queueFileUpload(unknownFile); }); it('should not set Content-Type header in multipart format', (done) => { @@ -635,7 +640,7 @@ describe('upload', () => { expect(contentType).to.be.undefined; done(); }); - upload._uploadFile(file); + upload._queueFileUpload(file); }); it('should not set X-Filename header in multipart format', (done) => { @@ -645,7 +650,7 @@ describe('upload', () => { expect(filename).to.be.undefined; done(); }); - upload._uploadFile(file); + upload._queueFileUpload(file); }); it('should ignore formDataName in raw format', (done) => { @@ -657,7 +662,7 @@ describe('upload', () => { expect(e.detail.formData).to.be.undefined; done(); }); - upload._uploadFile(file); + upload._queueFileUpload(file); }); it('should successfully complete upload in raw format', async () => { @@ -665,7 +670,7 @@ describe('upload', () => { const successSpy = sinon.spy(); upload.addEventListener('upload-success', successSpy); - upload._uploadFile(file); + upload._queueFileUpload(file); await clock.tickAsync(400); expect(successSpy.calledOnce).to.be.true; @@ -682,7 +687,7 @@ describe('upload', () => { expect(e.detail.formData).to.be.undefined; done(); }); - upload._uploadFile(file); + upload._queueFileUpload(file); }); }); });