diff --git a/packages/@uppy/xhr-upload/package.json b/packages/@uppy/xhr-upload/package.json index e279b9cde..66650a2eb 100644 --- a/packages/@uppy/xhr-upload/package.json +++ b/packages/@uppy/xhr-upload/package.json @@ -8,7 +8,8 @@ "scripts": { "build": "tsc --build tsconfig.build.json", "typecheck": "tsc --build", - "test": "vitest run --environment=jsdom --silent='passed-only'" + "test": "vitest run --silent='passed-only'", + "test:e2e": "vitest run --project browser" }, "keywords": [ "file uploader", @@ -44,8 +45,12 @@ }, "devDependencies": { "@uppy/core": "workspace:^", + "@uppy/dashboard": "workspace:^", + "@vitest/browser": "^3.2.4", "jsdom": "^26.1.0", + "msw": "^2.10.4", "nock": "^13.1.0", + "playwright": "1.57.0", "typescript": "^5.8.3", "vitest": "^3.2.4" }, diff --git a/packages/@uppy/xhr-upload/src/mocks/browser.ts b/packages/@uppy/xhr-upload/src/mocks/browser.ts new file mode 100644 index 000000000..6e462d0fd --- /dev/null +++ b/packages/@uppy/xhr-upload/src/mocks/browser.ts @@ -0,0 +1,3 @@ +import { setupWorker } from 'msw/browser' + +export const worker = setupWorker() diff --git a/packages/@uppy/xhr-upload/src/test-extend.ts b/packages/@uppy/xhr-upload/src/test-extend.ts new file mode 100644 index 000000000..ac6394842 --- /dev/null +++ b/packages/@uppy/xhr-upload/src/test-extend.ts @@ -0,0 +1,22 @@ +import { test as testBase } from 'vitest' +import { worker } from './mocks/browser.js' + +export const test = testBase.extend<{ worker: typeof worker }>({ + worker: [ + // biome-ignore lint/correctness/noEmptyPattern: dunno + async ({}, use) => { + // Start the worker before the test. + await worker.start() + + // Expose the worker object on the test's context. + await use(worker) + + // Remove any request handlers added in individual test cases. + // This prevents them from affecting unrelated tests. + worker.resetHandlers() + }, + { + auto: true, + }, + ], +}) diff --git a/packages/@uppy/xhr-upload/src/xhrUpload.browser.test.ts b/packages/@uppy/xhr-upload/src/xhrUpload.browser.test.ts new file mode 100644 index 000000000..722a0e426 --- /dev/null +++ b/packages/@uppy/xhr-upload/src/xhrUpload.browser.test.ts @@ -0,0 +1,142 @@ +import Uppy, { type UppyEventMap } from '@uppy/core' +import Dashboard from '@uppy/dashboard' +import { page, userEvent } from '@vitest/browser/context' +import { HttpResponse, http } from 'msw' +import { afterEach, beforeEach, describe, expect } from 'vitest' +import '@uppy/core/css/style.css' +import '@uppy/dashboard/css/style.css' +import XHRUpload from './index.js' +import { test } from './test-extend.js' + +type UploadCompleteResult = Parameters['complete']>[0] + +type MockUploadRequest = { + fieldNames: string[] + fileNames: string[] +} + +let uppy: Uppy | undefined + +function createUppy({ bundle = false }: { bundle?: boolean } = {}) { + const target = document.createElement('div') + document.body.appendChild(target) + + uppy = new Uppy({ debug: true }) + .use(Dashboard, { + target, + inline: true, + }) + .use(XHRUpload, { + endpoint: 'http://localhost/upload', + bundle, + }) + + return uppy +} + +function createMockFile(name: string, size: number = 16) { + return new File(['a'.repeat(size)], name, { type: 'text/plain' }) +} + +function waitForUploadComplete(uppy: Uppy) { + return new Promise((resolve) => + uppy.once('complete', resolve), + ) +} + +beforeEach(() => { + document.body.innerHTML = '' +}) + +afterEach(() => { + uppy?.destroy() + uppy = undefined +}) + +describe('XHRUpload browser mode', () => { + test('uploads a file in non-bundle mode', async ({ worker }) => { + const requests: MockUploadRequest[] = [] + worker.use( + http.post('http://localhost/upload', async ({ request }) => { + const formData = await request.formData() + const uploadedFiles = Array.from(formData.entries()).filter( + (entry): entry is [string, File] => entry[1] instanceof File, + ) + + requests.push({ + fieldNames: uploadedFiles.map(([fieldName]) => fieldName), + fileNames: uploadedFiles.map(([, file]) => file.name), + }) + + return HttpResponse.json({ url: 'http://localhost/uploads/regular' }) + }), + ) + + const uppy = createUppy() + const completePromise = waitForUploadComplete(uppy) + const fileInput = document.querySelector('.uppy-Dashboard-input')! + + await userEvent.upload(fileInput, createMockFile('regular.txt')) + await page.getByRole('button', { name: 'Upload 1 file' }).click() + + const result = await completePromise + + expect(result.failed).toHaveLength(0) + expect(result.successful).toHaveLength(1) + expect(requests).toHaveLength(1) + expect(requests[0]).toEqual({ + fieldNames: ['file'], + fileNames: ['regular.txt'], + }) + expect(uppy.getFiles()[0].response?.uploadURL).toBe( + 'http://localhost/uploads/regular', + ) + await expect + .element(page.getByText('Complete', { exact: true })) + .toBeVisible() + }) + + test('uploads files in a single request with bundle: true', async ({ + worker, + }) => { + const requests: MockUploadRequest[] = [] + worker.use( + http.post('http://localhost/upload', async ({ request }) => { + const formData = await request.formData() + const uploadedFiles = Array.from(formData.entries()).filter( + (entry): entry is [string, File] => entry[1] instanceof File, + ) + + requests.push({ + fieldNames: uploadedFiles.map(([fieldName]) => fieldName), + fileNames: uploadedFiles.map(([, file]) => file.name), + }) + + return HttpResponse.json({ url: 'http://localhost/uploads/bundled' }) + }), + ) + + const uppy = createUppy({ bundle: true }) + const completePromise = waitForUploadComplete(uppy) + const fileInput = document.querySelector('.uppy-Dashboard-input')! + + await userEvent.upload(fileInput, [ + createMockFile('bundle-a.txt'), + createMockFile('bundle-b.txt'), + ]) + await page.getByRole('button', { name: 'Upload 2 files' }).click() + + const result = await completePromise + + expect(result.failed).toHaveLength(0) + expect(result.successful).toHaveLength(2) + expect(requests).toHaveLength(1) + const sortedFileNames = [...requests[0].fileNames].sort() + expect(requests[0].fieldNames).toEqual(['files[]', 'files[]']) + expect(sortedFileNames).toEqual(['bundle-a.txt', 'bundle-b.txt']) + expect(uppy.getFiles().every((file) => file.response?.uploadURL)).toBe(true) + await expect + .element(page.getByText('Complete', { exact: true })) + .toBeVisible() + }) +}) diff --git a/packages/@uppy/xhr-upload/vitest.config.ts b/packages/@uppy/xhr-upload/vitest.config.ts new file mode 100644 index 000000000..b316316fc --- /dev/null +++ b/packages/@uppy/xhr-upload/vitest.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: 'unit', + include: [ + 'src/**/*.test.{ts,tsx}', + '!src/**/*.browser.test.{ts,tsx}', + ], + environment: 'jsdom', + }, + }, + { + test: { + name: 'browser', + include: ['src/**/*.browser.test.{ts,tsx}'], + browser: { + enabled: true, + provider: 'playwright', + instances: [{ browser: 'chromium' }], + }, + }, + }, + ], + }, +}) diff --git a/yarn.lock b/yarn.lock index e8297a822..83fe5acda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10480,9 +10480,13 @@ __metadata: dependencies: "@uppy/companion-client": "workspace:^" "@uppy/core": "workspace:^" + "@uppy/dashboard": "workspace:^" "@uppy/utils": "workspace:^" + "@vitest/browser": "npm:^3.2.4" jsdom: "npm:^26.1.0" + msw: "npm:^2.10.4" nock: "npm:^13.1.0" + playwright: "npm:1.57.0" typescript: "npm:^5.8.3" vitest: "npm:^3.2.4" peerDependencies: