diff --git a/package.json b/package.json index 35247448..36561364 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "@playwright/test": "^1.37.1", "@types/cors": "^2.8.12", "@types/express": "^4.17.13", + "@types/express-fileupload": "^1.5.0", "@types/express-rate-limit": "^6.0.0", "@types/follow-redirects": "^1.14.1", "@types/jest": "^27.0.3", @@ -145,6 +146,7 @@ "cz-conventional-changelog": "3.3.0", "engine.io-parser": "^5.2.1", "express": "^4.17.3", + "express-fileupload": "^1.5.1", "express-rate-limit": "^6.3.0", "follow-redirects": "^1.15.1", "got": "^11.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 612c46e3..098b4667 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ devDependencies: '@types/express': specifier: ^4.17.13 version: 4.17.17 + '@types/express-fileupload': + specifier: ^1.5.0 + version: 1.5.0 '@types/express-rate-limit': specifier: ^6.0.0 version: 6.0.0(express@4.18.2) @@ -94,6 +97,9 @@ devDependencies: express: specifier: ^4.17.3 version: 4.18.2 + express-fileupload: + specifier: ^1.5.1 + version: 1.5.1 express-rate-limit: specifier: ^6.3.0 version: 6.9.0(express@4.18.2) @@ -724,6 +730,7 @@ packages: /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + requiresBuild: true dependencies: '@jridgewell/trace-mapping': 0.3.9 dev: true @@ -1395,6 +1402,7 @@ packages: /@jridgewell/trace-mapping@0.3.9: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + requiresBuild: true dependencies: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.4.15 @@ -1794,18 +1802,22 @@ packages: /@tsconfig/node10@1.0.9: resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} + requiresBuild: true dev: true /@tsconfig/node12@1.0.11: resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + requiresBuild: true dev: true /@tsconfig/node14@1.0.3: resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + requiresBuild: true dev: true /@tsconfig/node16@1.0.4: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + requiresBuild: true dev: true /@types/babel__core@7.20.1: @@ -1850,6 +1862,12 @@ packages: '@types/node': 18.19.31 dev: true + /@types/busboy@1.5.4: + resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==} + dependencies: + '@types/node': 18.19.31 + dev: true + /@types/cacheable-request@6.0.3: resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} dependencies: @@ -1907,6 +1925,13 @@ packages: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} dev: true + /@types/express-fileupload@1.5.0: + resolution: {integrity: sha512-Y9v88IC5ItAxkKwfnyIi1y0jSZwTMY4jqXUQLZ3jFhYJlLdRnN919bKBNM8jbVVD2cxywA/uEC1kNNpZQGwx7Q==} + dependencies: + '@types/busboy': 1.5.4 + '@types/express': 4.17.17 + dev: true + /@types/express-rate-limit@6.0.0(express@4.18.2): resolution: {integrity: sha512-nZxo3nwU20EkTl/f2eGdndQkDIJYwkXIX4S3Vrp2jMdSdFJ6AWtIda8gOz0wiMuOFoeH/UUlCAiacz3x3eWNFA==} deprecated: This is a stub types definition. express-rate-limit provides its own type definitions, so you do not need this installed. @@ -2428,6 +2453,7 @@ packages: /arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + requiresBuild: true dev: true /argparse@1.0.10: @@ -3157,6 +3183,7 @@ packages: /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + requiresBuild: true dev: true /cross-env@7.0.3: @@ -3359,6 +3386,7 @@ packages: /diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + requiresBuild: true dev: true /dir-glob@3.0.1: @@ -3484,6 +3512,7 @@ packages: /error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + requiresBuild: true dependencies: is-arrayish: 0.2.1 dev: true @@ -3703,6 +3732,13 @@ packages: jest-message-util: 27.5.1 dev: true + /express-fileupload@1.5.1: + resolution: {integrity: sha512-LsYG1ALXEB7vlmjuSw8ABeOctMp8a31aUC5ZF55zuz7O2jLFnmJYrCv10py357ky48aEoBQ/9bVXgFynjvaPmA==} + engines: {node: '>=12.0.0'} + dependencies: + busboy: 1.6.0 + dev: true + /express-rate-limit@6.9.0(express@4.18.2): resolution: {integrity: sha512-AnISR3V8qy4gpKM62/TzYdoFO9NV84fBx0POXzTryHU/qGUJBWuVGd+JhbvtVmKBv37t8/afmqdnv16xWoQxag==} engines: {node: '>= 14.0.0'} @@ -4391,6 +4427,7 @@ packages: /is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + requiresBuild: true dev: true /is-binary-path@2.1.0: @@ -5753,6 +5790,7 @@ packages: /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + requiresBuild: true dependencies: callsites: 3.1.0 dev: true @@ -6195,6 +6233,7 @@ packages: /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + requiresBuild: true dev: true /resolve-from@5.0.0: @@ -7270,6 +7309,7 @@ packages: /v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + requiresBuild: true dev: true /v8-to-istanbul@8.1.1: @@ -7786,6 +7826,7 @@ packages: /yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} + requiresBuild: true dev: true /yocto-queue@0.1.0: diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts index 59f6a7d8..30ed9d23 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestController.ts @@ -48,9 +48,14 @@ export class XMLHttpRequestController { private requestBody?: XMLHttpRequestBodyInit | Document | null private responseBuffer: Uint8Array private events: Map> + private uploadEvents: Map< + keyof XMLHttpRequestEventTargetEventMap, + Array + > constructor(readonly initialRequest: XMLHttpRequest, public logger: Logger) { this.events = new Map() + this.uploadEvents = new Map() this.requestId = createRequestId() this.requestHeaders = new Headers() this.responseBuffer = new Uint8Array() @@ -200,6 +205,49 @@ export class XMLHttpRequestController { } }, }) + + /** + * Proxy the `.upload` property to gather the event listeners/callbacks. + */ + define( + this.request, + 'upload', + createProxy(this.request.upload, { + setProperty: ([propertyName, nextValue], invoke) => { + switch (propertyName) { + case 'onloadstart': + case 'onprogress': + case 'onaboart': + case 'onerror': + case 'onload': + case 'ontimeout': + case 'onloadend': { + const eventName = propertyName.slice( + 2 + ) as keyof XMLHttpRequestEventTargetEventMap + + this.registerUploadEvent(eventName, nextValue as Function) + } + } + + return invoke() + }, + methodCall: ([methodName, args], invoke) => { + switch (methodName) { + case 'addEventListener': { + const [eventName, listener] = args as [ + keyof XMLHttpRequestEventTargetEventMap, + Function + ] + this.registerUploadEvent(eventName, listener) + this.logger.info('upload.addEventListener', eventName, listener) + + return invoke() + } + } + }, + }) + ) } private registerEvent( @@ -213,11 +261,52 @@ export class XMLHttpRequestController { this.logger.info('registered event "%s"', eventName, listener) } + private registerUploadEvent( + eventName: keyof XMLHttpRequestEventTargetEventMap, + listener: Function + ): void { + const prevEvents = this.uploadEvents.get(eventName) || [] + const nextEvents = prevEvents.concat(listener) + this.uploadEvents.set(eventName, nextEvents) + + this.logger.info('registered upload event "%s"', eventName, listener) + } + /** * Responds to the current request with the given * Fetch API `Response` instance. */ - public respondWith(response: Response): void { + public async respondWith(response: Response): Promise { + /** + * Dispatch request upload events for requests with a body. + * @see https://github.com/mswjs/interceptors/issues/573 + */ + if (this.requestBody != null) { + const totalRequestBodyLength = this.requestHeaders.has('content-length') + ? Number(this.requestHeaders.get('content-length')) + : await getXMLHttpRequestBodyInitLength( + this.requestBody, + this.requestHeaders + ) + + this.trigger('loadstart', this.request.upload, { + loaded: 0, + total: totalRequestBodyLength, + }) + this.trigger('progress', this.request.upload, { + loaded: totalRequestBodyLength, + total: totalRequestBodyLength, + }) + this.trigger('load', this.request.upload, { + loaded: totalRequestBodyLength, + total: totalRequestBodyLength, + }) + this.trigger('loadend', this.request.upload, { + loaded: totalRequestBodyLength, + total: totalRequestBodyLength, + }) + } + this.logger.info( 'responding with a mocked response: %d %s', response.status, @@ -303,7 +392,7 @@ export class XMLHttpRequestController { }, }) - const totalResponseBodyLength = response.headers.has('Content-Length') + const totalResponseBodyLength = response.headers.has('content-length') ? Number(response.headers.get('Content-Length')) : /** * @todo Infer the response body length from the response body. @@ -312,7 +401,7 @@ export class XMLHttpRequestController { this.logger.info('calculated response body length', totalResponseBodyLength) - this.trigger('loadstart', { + this.trigger('loadstart', this.request, { loaded: 0, total: totalResponseBodyLength, }) @@ -325,12 +414,12 @@ export class XMLHttpRequestController { this.setReadyState(this.request.DONE) - this.trigger('load', { + this.trigger('load', this.request, { loaded: this.responseBuffer.byteLength, total: totalResponseBodyLength, }) - this.trigger('loadend', { + this.trigger('loadend', this.request, { loaded: this.responseBuffer.byteLength, total: totalResponseBodyLength, }) @@ -354,7 +443,7 @@ export class XMLHttpRequestController { this.logger.info('read response body chunk:', value) this.responseBuffer = concatArrayBuffer(this.responseBuffer, value) - this.trigger('progress', { + this.trigger('progress', this.request, { loaded: this.responseBuffer.byteLength, total: totalResponseBodyLength, }) @@ -485,8 +574,8 @@ export class XMLHttpRequestController { this.logger.info('responding with an error') this.setReadyState(this.request.DONE) - this.trigger('error') - this.trigger('loadend') + this.trigger('error', this.request) + this.trigger('loadend', this.request) } /** @@ -511,7 +600,7 @@ export class XMLHttpRequestController { if (nextReadyState !== this.request.UNSENT) { this.logger.info('triggerring "readystatechange" event...') - this.trigger('readystatechange') + this.trigger('readystatechange', this.request) } } @@ -522,20 +611,27 @@ export class XMLHttpRequestController { EventName extends keyof (XMLHttpRequestEventTargetEventMap & { readystatechange: ProgressEvent }) - >(eventName: EventName, options?: ProgressEventInit): void { - const callback = this.request[`on${eventName}`] - const event = createEvent(this.request, eventName, options) + >( + eventName: EventName, + target: XMLHttpRequest | XMLHttpRequestUpload, + options?: ProgressEventInit + ): void { + const callback = (target as XMLHttpRequest)[`on${eventName}`] + const event = createEvent(target, eventName, options) this.logger.info('trigger "%s"', eventName, options || '') // Invoke direct callbacks. if (typeof callback === 'function') { this.logger.info('found a direct "%s" callback, calling...', eventName) - callback.call(this.request, event) + callback.call(target as XMLHttpRequest, event) } // Invoke event listeners. - for (const [registeredEventName, listeners] of this.events) { + const events = + target instanceof XMLHttpRequestUpload ? this.uploadEvents : this.events + + for (const [registeredEventName, listeners] of events) { if (registeredEventName === eventName) { this.logger.info( 'found %d listener(s) for "%s" event, calling...', @@ -543,7 +639,7 @@ export class XMLHttpRequestController { eventName ) - listeners.forEach((listener) => listener.call(this.request, event)) + listeners.forEach((listener) => listener.call(target, event)) } } } @@ -551,7 +647,7 @@ export class XMLHttpRequestController { /** * Converts this `XMLHttpRequest` instance into a Fetch API `Request` instance. */ - public toFetchApiRequest(): Request { + private toFetchApiRequest(): Request { this.logger.info('converting request to a Fetch API Request...') const fetchRequest = new Request(this.url.href, { @@ -626,3 +722,50 @@ function define( value, }) } + +async function getXMLHttpRequestBodyInitLength( + body: XMLHttpRequestBodyInit | Document, + headers: Headers +): Promise { + if (typeof body === 'object' && 'byteLength' in body) { + return body.byteLength + } + + if (body instanceof Blob) { + return body.size + } + + if (body instanceof FormData) { + const lines: Array = [] + const contentType = + headers.get('content-type') || 'application/octet-stream' + + for (const [name, entry] of body) { + lines.push(`------WebKitFormBoundary1234567890123456`) + lines.push(`content-type: ${contentType}`) + + if (typeof entry === 'string') { + lines.push(`content-disposition: form-data; name="${name}"`) + lines.push(``) + lines.push(entry) + } else { + lines.push( + `content-disposition: form-data; name="${name}"; filename="${entry.name}"` + ) + lines.push(``) + lines.push(await entry.text()) + } + } + + lines.push('------WebKitFormBoundary1234567890123456--') + lines.push(``) + + return lines.join('\r\n').length + } + + if (body instanceof Document) { + return body.documentElement.innerHTML.length + } + + return body.toString().length +} diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts index 9dd89cff..997f06fa 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts @@ -66,8 +66,8 @@ export function createXMLHttpRequestProxy({ requestId, controller, emitter, - onResponse: (response) => { - this.respondWith(response) + onResponse: async (response) => { + await this.respondWith(response) }, onRequestError: () => { this.errorWith(new TypeError('Network error')) diff --git a/src/interceptors/XMLHttpRequest/polyfills/EventPolyfill.ts b/src/interceptors/XMLHttpRequest/polyfills/EventPolyfill.ts index b02c2cd6..4c44e509 100644 --- a/src/interceptors/XMLHttpRequest/polyfills/EventPolyfill.ts +++ b/src/interceptors/XMLHttpRequest/polyfills/EventPolyfill.ts @@ -1,8 +1,8 @@ export class EventPolyfill implements Event { - readonly AT_TARGET: number = 0 - readonly BUBBLING_PHASE: number = 0 - readonly CAPTURING_PHASE: number = 0 - readonly NONE: number = 0 + readonly NONE = 0 + readonly CAPTURING_PHASE = 1 + readonly AT_TARGET = 2 + readonly BUBBLING_PHASE = 3 public type: string = '' public srcElement: EventTarget | null = null diff --git a/src/interceptors/XMLHttpRequest/utils/createEvent.ts b/src/interceptors/XMLHttpRequest/utils/createEvent.ts index 93358c9c..bc35ffd3 100644 --- a/src/interceptors/XMLHttpRequest/utils/createEvent.ts +++ b/src/interceptors/XMLHttpRequest/utils/createEvent.ts @@ -4,7 +4,7 @@ import { ProgressEventPolyfill } from '../polyfills/ProgressEventPolyfill' const SUPPORTS_PROGRESS_EVENT = typeof ProgressEvent !== 'undefined' export function createEvent( - target: XMLHttpRequest, + target: XMLHttpRequest | XMLHttpRequestUpload, type: string, init?: ProgressEventInit ): EventPolyfill { diff --git a/src/utils/handleRequest.ts b/src/utils/handleRequest.ts index 69645c5e..84316e0e 100644 --- a/src/utils/handleRequest.ts +++ b/src/utils/handleRequest.ts @@ -22,7 +22,7 @@ interface HandleRequestOptions { * Called when the request has been handled * with the given `Response` instance. */ - onResponse: (response: Response) => void + onResponse: (response: Response) => void | Promise /** * Called when the request has been handled @@ -43,7 +43,7 @@ interface HandleRequestOptions { export async function handleRequest( options: HandleRequestOptions ): Promise { - const handleResponse = (response: Response | Error): true => { + const handleResponse = async (response: Response | Error) => { if (response instanceof Error) { options.onError(response) } @@ -52,13 +52,13 @@ export async function handleRequest( else if (isResponseError(response)) { options.onRequestError(response) } else { - options.onResponse(response) + await options.onResponse(response) } return true } - const handleResponseError = (error: unknown): boolean => { + const handleResponseError = async (error: unknown): Promise => { // Forward the special interceptor error instances // to the developer. These must not be handled in any way. if (error instanceof InterceptorError) { @@ -73,7 +73,7 @@ export async function handleRequest( // Handle thrown responses. if (error instanceof Response) { - return handleResponse(error) + return await handleResponse(error) } return false @@ -140,7 +140,7 @@ export async function handleRequest( if (result.error) { // Handle the error during the request listener execution. // These can be thrown responses or request errors. - if (handleResponseError(result.error)) { + if (await handleResponseError(result.error)) { return true } diff --git a/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.runtime.js b/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.runtime.js new file mode 100644 index 00000000..7bb0e85d --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.runtime.js @@ -0,0 +1,40 @@ +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() +interceptor.apply() +window.interceptor = interceptor + +window.waitForXMLHttpRequest = (xhr) => { + return new Promise((resolve, reject) => { + xhr.onloadend = resolve + xhr.onerror = () => reject(new Error('XMLHttpRequest errored')) + }) +} + +window.spyOnXMLHttpRequest = (xhr) => { + const listeners = [] + const callbacks = [] + + const pushListener = ({ type, loaded, total }) => { + listeners.push({ type, loaded, total }) + } + const pushCallback = ({ type, loaded, total }) => { + callbacks.push({ type, loaded, total }) + } + + xhr.upload.addEventListener('loadstart', pushListener) + xhr.upload.addEventListener('progress', pushListener) + xhr.upload.addEventListener('load', pushListener) + xhr.upload.addEventListener('loadend', pushListener) + xhr.upload.addEventListener('timeout', pushListener) + xhr.upload.addEventListener('error', pushListener) + + xhr.upload.onloadstart = pushCallback + xhr.upload.onprogress = pushCallback + xhr.upload.onload = pushCallback + xhr.upload.onloadend = pushCallback + xhr.upload.ontimeout = pushCallback + xhr.upload.onerror = pushCallback + + return { listeners, callbacks } +} diff --git a/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.test.ts new file mode 100644 index 00000000..1001b482 --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-upload.browser.test.ts @@ -0,0 +1,387 @@ +/** + * @see https://github.com/mswjs/interceptors/issues/573 + */ +import fileUpload from 'express-fileupload' +import { HttpServer } from '@open-draft/test-server/http' +import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { test, expect } from '../../../playwright.extend' +import { useCors } from '../../../helpers' + +declare global { + interface Window { + interceptor: XMLHttpRequestInterceptor + spyOnXMLHttpRequest: (xhr: XMLHttpRequest) => { + listeners: Array + callbacks: Array + } + waitForXMLHttpRequest: (xhr: XMLHttpRequest) => Promise + } +} + +type XMLHttpRequestSpyEntry = { + type: keyof XMLHttpRequestEventMap + loaded: number + total: number +} + +const httpServer = new HttpServer((app) => { + app.use(useCors) + + app.post('/upload', fileUpload(), (req, res) => { + if (req.get('content-type')?.includes('form-data') && !req.files) { + return res.status(400).send('Missing file') + } + return res.status(200).end() + }) +}) + +test.beforeAll(async () => { + await httpServer.listen() +}) + +test.afterAll(async () => { + await httpServer.close() +}) + +test('supports uploading a plain string to the original server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./xhr-upload.browser.runtime.js')) + + const { xhr, listeners, callbacks } = await page.evaluate((url) => { + const xhr = new XMLHttpRequest() + const spy = window.spyOnXMLHttpRequest(xhr) + xhr.open('POST', url) + xhr.send('hello world') + + return window.waitForXMLHttpRequest(xhr).then(() => ({ ...spy, xhr })) + }, httpServer.http.url('/upload')) + + expect(xhr.status).toBe(200) + expect(listeners).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) + expect(callbacks).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) +}) + +test('supports uploading a Blob to the original server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./xhr-upload.browser.runtime.js')) + + const { xhr, listeners, callbacks } = await page.evaluate((url) => { + const xhr = new XMLHttpRequest() + const spy = window.spyOnXMLHttpRequest(xhr) + xhr.open('POST', url) + xhr.send(new Blob(['hello world'])) + + return window.waitForXMLHttpRequest(xhr).then(() => ({ ...spy, xhr })) + }, httpServer.http.url('/upload')) + + expect(xhr.status).toBe(200) + expect(listeners).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) + expect(callbacks).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) +}) + +test('supports uploading URLSearchParams to the original server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./xhr-upload.browser.runtime.js')) + + const { xhr, listeners, callbacks } = await page.evaluate((url) => { + const xhr = new XMLHttpRequest() + const spy = window.spyOnXMLHttpRequest(xhr) + xhr.open('POST', url) + xhr.send(new URLSearchParams({ hello: 'world' })) + + return window.waitForXMLHttpRequest(xhr).then(() => ({ ...spy, xhr })) + }, httpServer.http.url('/upload')) + + expect(xhr.status).toBe(200) + expect(listeners).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) + expect(callbacks).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) +}) + +test('supports uploading FormData (single file) to the original server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./xhr-upload.browser.runtime.js')) + + const { xhr, listeners, callbacks } = await page.evaluate((url) => { + const data = new FormData() + data.set('data', new File(['hello world'], 'data.txt')) + + const xhr = new XMLHttpRequest() + const spy = window.spyOnXMLHttpRequest(xhr) + xhr.open('POST', url) + xhr.send(data) + + return window.waitForXMLHttpRequest(xhr).then(() => ({ ...spy, xhr })) + }, httpServer.http.url('/upload')) + + expect(xhr.status).toBe(200) + expect(listeners).toEqual([ + { type: 'loadstart', loaded: 0, total: 207 }, + { type: 'progress', loaded: 207, total: 207 }, + { type: 'load', loaded: 207, total: 207 }, + { type: 'loadend', loaded: 207, total: 207 }, + ]) + expect(callbacks).toEqual([ + { type: 'loadstart', loaded: 0, total: 207 }, + { type: 'progress', loaded: 207, total: 207 }, + { type: 'load', loaded: 207, total: 207 }, + { type: 'loadend', loaded: 207, total: 207 }, + ]) +}) + +test('supports uploading FormData (multiple files) to the original server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./xhr-upload.browser.runtime.js')) + + const { xhr, listeners, callbacks } = await page.evaluate((url) => { + const data = new FormData() + data.set('file1', new File(['hello world'], 'hello.txt')) + data.set('file2', new File(['goodbye cosm'], 'goodbye.txt')) + + const xhr = new XMLHttpRequest() + const spy = window.spyOnXMLHttpRequest(xhr) + xhr.open('POST', url) + xhr.send(data) + + return window.waitForXMLHttpRequest(xhr).then(() => ({ ...spy, xhr })) + }, httpServer.http.url('/upload')) + + expect(xhr.status).toBe(200) + expect(listeners).toEqual([ + { type: 'loadstart', loaded: 0, total: 377 }, + { type: 'progress', loaded: 377, total: 377 }, + { type: 'load', loaded: 377, total: 377 }, + { type: 'loadend', loaded: 377, total: 377 }, + ]) + expect(callbacks).toEqual([ + { type: 'loadstart', loaded: 0, total: 377 }, + { type: 'progress', loaded: 377, total: 377 }, + { type: 'load', loaded: 377, total: 377 }, + { type: 'loadend', loaded: 377, total: 377 }, + ]) +}) + +/** + * Mocked scenarios. + */ + +test('supports uploading a plain string to a mocked server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./xhr-upload.browser.runtime.js')) + await page.evaluate(() => { + window.interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response()) + }) + }) + + const { xhr, listeners, callbacks } = await page.evaluate(() => { + const xhr = new XMLHttpRequest() + const spy = window.spyOnXMLHttpRequest(xhr) + xhr.open('POST', '/upload') + xhr.send('hello world') + + return window.waitForXMLHttpRequest(xhr).then(() => ({ ...spy, xhr })) + }) + + expect(xhr.status).toBe(200) + expect(listeners).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) + expect(callbacks).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) +}) + +test('supports uploading a Blob to a mocked server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./xhr-upload.browser.runtime.js')) + await page.evaluate(() => { + window.interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response()) + }) + }) + + const { xhr, listeners, callbacks } = await page.evaluate(() => { + const xhr = new XMLHttpRequest() + const spy = window.spyOnXMLHttpRequest(xhr) + xhr.open('POST', '/upload') + xhr.send(new Blob(['hello world'])) + + return window.waitForXMLHttpRequest(xhr).then(() => ({ ...spy, xhr })) + }) + + expect(xhr.status).toBe(200) + expect(listeners).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) + expect(callbacks).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) +}) + +test('supports uploading URLSearchParams to a mocked server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./xhr-upload.browser.runtime.js')) + await page.evaluate(() => { + window.interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response()) + }) + }) + + const { xhr, listeners, callbacks } = await page.evaluate(() => { + const xhr = new XMLHttpRequest() + const spy = window.spyOnXMLHttpRequest(xhr) + xhr.open('POST', '/upload') + xhr.send(new URLSearchParams({ hello: 'world' })) + + return window.waitForXMLHttpRequest(xhr).then(() => ({ ...spy, xhr })) + }) + + expect(xhr.status).toBe(200) + expect(listeners).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) + expect(callbacks).toEqual([ + { type: 'loadstart', loaded: 0, total: 11 }, + { type: 'progress', loaded: 11, total: 11 }, + { type: 'load', loaded: 11, total: 11 }, + { type: 'loadend', loaded: 11, total: 11 }, + ]) +}) + +test('supports uploading FormData (single file) to a mocked server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./xhr-upload.browser.runtime.js')) + await page.evaluate(() => { + window.interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response()) + }) + }) + + const { xhr, listeners, callbacks } = await page.evaluate(() => { + const data = new FormData() + data.set('data', new File(['hello world'], 'data.txt')) + + const xhr = new XMLHttpRequest() + const spy = window.spyOnXMLHttpRequest(xhr) + xhr.open('POST', '/upload') + xhr.send(data) + + return window.waitForXMLHttpRequest(xhr).then(() => ({ ...spy, xhr })) + }) + + expect(xhr.status).toBe(200) + expect(listeners).toEqual([ + { type: 'loadstart', loaded: 0, total: 207 }, + { type: 'progress', loaded: 207, total: 207 }, + { type: 'load', loaded: 207, total: 207 }, + { type: 'loadend', loaded: 207, total: 207 }, + ]) + expect(callbacks).toEqual([ + { type: 'loadstart', loaded: 0, total: 207 }, + { type: 'progress', loaded: 207, total: 207 }, + { type: 'load', loaded: 207, total: 207 }, + { type: 'loadend', loaded: 207, total: 207 }, + ]) +}) + +test('supports uploading FormData (multiple files) to a mocked server', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./xhr-upload.browser.runtime.js')) + await page.evaluate(() => { + window.interceptor.on('request', ({ controller }) => { + controller.respondWith(new Response()) + }) + }) + + const { xhr, listeners, callbacks } = await page.evaluate(() => { + const data = new FormData() + data.set('file1', new File(['hello world'], 'hello.txt')) + data.set('file2', new File(['goodbye cosm'], 'goodbye.txt')) + + const xhr = new XMLHttpRequest() + const spy = window.spyOnXMLHttpRequest(xhr) + xhr.open('POST', '/upload') + xhr.send(data) + + return window.waitForXMLHttpRequest(xhr).then(() => ({ ...spy, xhr })) + }) + + expect(xhr.status).toBe(200) + expect(listeners).toEqual([ + { type: 'loadstart', loaded: 0, total: 377 }, + { type: 'progress', loaded: 377, total: 377 }, + { type: 'load', loaded: 377, total: 377 }, + { type: 'loadend', loaded: 377, total: 377 }, + ]) + expect(callbacks).toEqual([ + { type: 'loadstart', loaded: 0, total: 377 }, + { type: 'progress', loaded: 377, total: 377 }, + { type: 'load', loaded: 377, total: 377 }, + { type: 'loadend', loaded: 377, total: 377 }, + ]) +})