diff --git a/README.md b/README.md index be087caa..7c4839fc 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ class NodeClientRequest extends ClientRequest { async end(...args) { // Check if there's a mocked response for this request. // You control this in the "resolver" function. - const mockedResponse = await resolver(isomorphicRequest) + const mockedResponse = await resolver(request) // If there is a mocked response, use it to respond to this // request, finalizing it afterward as if it received that @@ -81,9 +81,9 @@ This library extends (or patches, where applicable) the following native modules - `XMLHttpRequest` - `fetch` -Once extended, it intercepts and normalizes all requests to the _isomorphic request instances_. The isomorphic request is an abstract representation of the request coming from different sources (`ClientRequest`, `XMLHttpRequest`, `window.Request`, etc.) that allows us to handle such requests in the same, unified manner. +Once extended, it intercepts and normalizes all requests to the Fetch API `Request` instances. This way, no matter the request source (`http.ClientRequest`, `XMLHttpRequest`, `window.Request`, etc), you always get a specification-compliant request instance to work with. -You can respond to an isomorphic request using an _isomorphic response_. In a similar way, the isomorphic response is a representation of the response to use for different requests. Responding to requests differs substantially when using modules like `http` or `XMLHttpRequest`. This library takes the responsibility for coercing isomorphic responses into appropriate responses depending on the request module automatically. +You can respond to the intercepted request by constructing a Fetch API Response instance. Instead of designing custom abstractions, this library respects the Fetch API specification and takes the responsibility to coerce a single response declaration to the appropriate response formats based on the request-issuing modules (like `http.OutgoingMessage` to respond to `http.ClientRequest`, or updating `XMLHttpRequest` response-related properties). ## What this library doesn't do @@ -116,19 +116,14 @@ interceptor.apply() // Listen to any "http.ClientRequest" being dispatched, // and log its method and full URL. -interceptor.on('request', (request) => { - console.log(request.method, request.url.href) +interceptor.on('request', (request, requestId) => { + console.log(request.method, request.url) }) // Listen to any responses sent to "http.ClientRequest". // Note that this listener is read-only and cannot affect responses. interceptor.on('response', (response, request) => { - console.log( - 'response to %s %s was:', - request.method, - request.url.href, - response - ) + console.log('response to %s %s was:', request.method, request.url, response) }) ``` @@ -203,39 +198,38 @@ interceptor.on('request', listener) ## Introspecting requests -All HTTP request interceptors emit a "request" event. In the listener to this event, they expose an isomorphic `request` instance—a normalized representation of the captured request. +All HTTP request interceptors emit a "request" event. In the listener to this event, they expose a `request` reference, which is a [Fetch API Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) instance. -> There are many ways to describe a request in Node.js, that's why this library exposes you a custom request instance that abstracts those details away from you, making request listeners uniform. +> There are many ways to describe a request in Node.js but this library coerces different request definitions to a single specification-compliant `Request` instance to make the handling consistent. ```js -interceptor.on('reqest', (request) => {}) +interceptor.on('reqest', (request, requestId) => { + console.log(request.method, request.url) +}) ``` -The exposed `request` partially implements Fetch API [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) specification, containing the following properties and methods: +Since the exposed `request` instance implements the Fetch API specification, you can operate with it just as you do with the regular browser request. For example, this is how you would read the request body as JSON: -```ts -interface IsomorphicRequest { - id: string - url: URL - method: string - headers: Headers - credentials: 'omit' | 'same-origin' | 'include' - bodyUsed: boolean - clone(): IsomorphicRequest - arrayBuffer(): Promise - text(): Promise - json(): Promise> -} +```js +interceptor.on('request', async (request, requestId) => { + const json = await request.clone().json() +}) ``` -For example, this is how you would read a JSON request body: +> **Do not forget to clone the request before reading its body!** + +## Modifying requests + +Request representations are readonly. You can, however, mutate the intercepted request's headers in the "request" listener: ```js -interceptor.on('request', async (request) => { - const json = await request.json() +interceptor.on('request', (request) => { + request.headers.set('X-My-Header', 'true') }) ``` +> This restriction is done so that the library wouldn't have to unnecessarily synchronize the actual request instance and its Fetch API request representation. As of now, this library is not meant to be used as a full-scale proxy. + ## Mocking responses Although this library can be used purely for request introspection purposes, you can also affect request resolution by responding to any intercepted request within the "request" event. @@ -243,21 +237,29 @@ Although this library can be used purely for request introspection purposes, you Use the `request.respondWith()` method to respond to a request with a mocked response: ```js -interceptor.on('request', (request) => { - request.respondWith({ - status: 200, - statusText: 'OK', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Maverick', - }), - }) +interceptor.on('request', (request, requestId) => { + request.respondWith( + new Response( + JSON.stringify({ + firstName: 'John', + lastName: 'Maverick', + }), + { + status: 201, + statusText: 'Created', + headers: { + 'Content-Type': 'application/json', + }, + } + ) + ) }) ``` +> We use Fetch API `Response` class as the middleground for mocked response definition. This library then coerces the response instance to the appropriate response format (e.g. to `http.OutgoingMessage` in the case of `http.ClientRequest`). + +**The `Response` class is built-in in since Node.js 18. Use a Fetch API-compatible polyfill, like `node-fetch`, for older versions of Node.js.`** + Note that a single request _can only be handled once_. You may want to introduce conditional logic, like routing, in your request listener but it's generally advised to use a higher-level library like [Mock Service Worker](https://github.com/mswjs/msw) that does request matching for you. Requests must be responded to within the same tick as the request listener. This means you cannot respond to a request using `setTimeout`, as this will delegate the callback to the next tick. If you wish to introduce asynchronous side-effects in the listener, consider making it an `async` function, awaiting any side-effects you need. @@ -265,9 +267,9 @@ Requests must be responded to within the same tick as the request listener. This ```js // Respond to all requests with a 500 response // delayed by 500ms. -interceptor.on('request', async (request) => { +interceptor.on('request', async (request, requestId) => { await sleep(500) - request.respondWith({ status: 500 }) + request.respondWith(new Response(null, { status: 500 })) }) ``` @@ -310,7 +312,7 @@ const interceptor = new BatchInterceptor({ interceptor.apply() -interceptor.on('request', (request) => { +interceptor.on('request', (request, requestId) => { // Inspect the intercepted "request". // Optionally, return a mocked response. }) @@ -358,7 +360,7 @@ const resolver = new RemoteHttpResolver({ process: appProcess, }) -resolver.on('request', (request) => { +resolver.on('request', (request, requestId) => { // Optionally, return a mocked response // for a request that occurred in the "appProcess". }) diff --git a/package.json b/package.json index b146991a..e76b4880 100644 --- a/package.json +++ b/package.json @@ -62,11 +62,12 @@ "superagent": "^6.1.0", "supertest": "^6.1.6", "ts-jest": "^27.1.1", - "typescript": "4.3.5", + "typescript": "4.4.4", "wait-for-expect": "^3.0.2" }, "dependencies": { "@open-draft/until": "^1.0.3", + "@remix-run/web-fetch": "^4.3.1", "@types/debug": "^4.1.7", "@xmldom/xmldom": "^0.8.3", "debug": "^4.3.3", diff --git a/src/InteractiveIsomorphicRequest.ts b/src/InteractiveIsomorphicRequest.ts deleted file mode 100644 index 47e421a6..00000000 --- a/src/InteractiveIsomorphicRequest.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { invariant } from 'outvariant' -import { MockedResponse } from './glossary' -import { IsomorphicRequest } from './IsomorphicRequest' -import { createLazyCallback, LazyCallback } from './utils/createLazyCallback' - -export class InteractiveIsomorphicRequest extends IsomorphicRequest { - public respondWith: LazyCallback<(response: MockedResponse) => void> - - constructor(request: IsomorphicRequest) { - super(request) - - this.respondWith = createLazyCallback({ - maxCalls: 1, - maxCallsCallback: () => { - invariant( - false, - 'Failed to respond to "%s %s" request: the "request" event has already been responded to.', - this.method, - this.url.href - ) - }, - }) - } -} diff --git a/src/IsomorphicRequest.test.ts b/src/IsomorphicRequest.test.ts deleted file mode 100644 index 87bb242d..00000000 --- a/src/IsomorphicRequest.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Headers } from 'headers-polyfill' -import { IsomorphicRequest } from './IsomorphicRequest' -import { encodeBuffer } from './utils/bufferUtils' - -const url = new URL('http://dummy') -const body = encodeBuffer(JSON.stringify({ hello: 'world' })) - -it('reads request body as json', async () => { - const request = new IsomorphicRequest(url, { body }) - - expect(request.bodyUsed).toBe(false) - expect(await request.json()).toEqual({ hello: 'world' }) - expect(request.bodyUsed).toBe(true) - expect(() => request.json()).rejects.toThrow( - 'Failed to execute "json" on "IsomorphicRequest": body buffer already read' - ) -}) - -it('reads request body as text', async () => { - const request = new IsomorphicRequest(url, { body }) - - expect(request.bodyUsed).toBe(false) - expect(await request.text()).toEqual(JSON.stringify({ hello: 'world' })) - expect(request.bodyUsed).toBe(true) - expect(() => request.text()).rejects.toThrow( - 'Failed to execute "text" on "IsomorphicRequest": body buffer already read' - ) -}) - -it('reads request body as array buffer', async () => { - const request = new IsomorphicRequest(url, { body }) - - expect(request.bodyUsed).toBe(false) - expect(await request.arrayBuffer()).toEqual(encodeBuffer(`{"hello":"world"}`)) - expect(request.bodyUsed).toBe(true) - expect(() => request.arrayBuffer()).rejects.toThrow( - 'Failed to execute "arrayBuffer" on "IsomorphicRequest": body buffer already read' - ) -}) - -it('returns default method', () => { - const request = new IsomorphicRequest(url, { body }) - expect(request.method).toEqual('GET') -}) - -it('returns given method', () => { - const request = new IsomorphicRequest(url, { body, method: 'POST' }) - expect(request.method).toEqual('POST') -}) - -it('returns given credentials', () => { - const request = new IsomorphicRequest(url, { body, credentials: 'include' }) - expect(request.credentials).toEqual('include') -}) - -it('returns default credentials', () => { - const request = new IsomorphicRequest(url, { body }) - expect(request.credentials).toEqual('same-origin') -}) - -it('returns empty headers if not provided', () => { - const request = new IsomorphicRequest(url, { body }) - expect(request.headers).toEqual(new Headers()) -}) - -it('returns given headers', () => { - const request = new IsomorphicRequest(url, { - body, - headers: { 'Content-Type': 'application/json' }, - }) - expect(request.headers).toEqual( - new Headers({ 'Content-Type': 'application/json' }) - ) -}) - -it('returns a copy of isomorphic request instance', () => { - const request = new IsomorphicRequest(url, { - body, - headers: { foo: 'bar' }, - }) - const derivedRequest = new IsomorphicRequest(request) - - expect(request.id).toBe(derivedRequest.id) - expect(request.url.href).toBe(derivedRequest.url.href) - expect(request['_body']).toEqual(derivedRequest['_body']) - expect(request.headers).toEqual(derivedRequest.headers) - expect(request.method).toBe(derivedRequest.method) - expect(request.credentials).toBe(derivedRequest.credentials) - expect(request.bodyUsed).toBe(false) -}) - -it('clones current isomorphic request instance', () => { - const request = new IsomorphicRequest(url, { - body, - headers: { foo: 'bar' }, - }) - const clonedRequest = request.clone() - - expect(clonedRequest.id).toBe(request.id) - expect(clonedRequest.method).toBe(request.method) - expect(clonedRequest.url.href).toBe(request.url.href) - expect(clonedRequest.headers).toEqual(request.headers) - expect(clonedRequest.credentials).toBe(request.credentials) - expect(clonedRequest['_body']).toEqual(request['_body']) - expect(clonedRequest.bodyUsed).toBe(false) -}) diff --git a/src/IsomorphicRequest.ts b/src/IsomorphicRequest.ts deleted file mode 100644 index 58faad58..00000000 --- a/src/IsomorphicRequest.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Headers } from 'headers-polyfill' -import { invariant } from 'outvariant' -import { decodeBuffer } from './utils/bufferUtils' -import { uuidv4 } from './utils/uuid' - -export interface RequestInit { - method?: string - headers?: Record | Headers - credentials?: RequestCredentials - body?: ArrayBuffer -} - -export class IsomorphicRequest { - public id: string - public readonly url: URL - public readonly method: string - public readonly headers: Headers - public readonly credentials: RequestCredentials - - private readonly _body: ArrayBuffer - private _bodyUsed: boolean - - constructor(url: URL) - constructor(url: URL, init: RequestInit) - constructor(request: IsomorphicRequest) - constructor(input: IsomorphicRequest | URL, init: RequestInit = {}) { - const defaultBody = new ArrayBuffer(0) - this._bodyUsed = false - - if (input instanceof IsomorphicRequest) { - this.id = input.id - this.url = input.url - this.method = input.method - this.headers = input.headers - this.credentials = input.credentials - this._body = input._body || defaultBody - return - } - - this.id = uuidv4() - this.url = input - this.method = init.method || 'GET' - this.headers = new Headers(init.headers) - this.credentials = init.credentials || 'same-origin' - this._body = init.body || defaultBody - } - - public get bodyUsed(): boolean { - return this._bodyUsed - } - - public async text(): Promise { - invariant( - !this.bodyUsed, - 'Failed to execute "text" on "IsomorphicRequest": body buffer already read' - ) - - this._bodyUsed = true - return decodeBuffer(this._body) - } - - public async json(): Promise { - invariant( - !this.bodyUsed, - 'Failed to execute "json" on "IsomorphicRequest": body buffer already read' - ) - - this._bodyUsed = true - const text = decodeBuffer(this._body) - return JSON.parse(text) - } - - public async arrayBuffer(): Promise { - invariant( - !this.bodyUsed, - 'Failed to execute "arrayBuffer" on "IsomorphicRequest": body buffer already read' - ) - - this._bodyUsed = true - return this._body - } - - public clone(): IsomorphicRequest { - return new IsomorphicRequest(this) - } -} diff --git a/src/RemoteHttpInterceptor.ts b/src/RemoteHttpInterceptor.ts index 158217a1..09919713 100644 --- a/src/RemoteHttpInterceptor.ts +++ b/src/RemoteHttpInterceptor.ts @@ -1,14 +1,34 @@ import { ChildProcess } from 'child_process' -import { Headers } from 'headers-polyfill' +import { Response } from '@remix-run/web-fetch' +import { Headers, HeadersObject, headersToObject } from 'headers-polyfill' import { HttpRequestEventMap } from './glossary' import { Interceptor } from './Interceptor' import { BatchInterceptor } from './BatchInterceptor' import { ClientRequestInterceptor } from './interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest' -import { toIsoResponse } from './utils/toIsoResponse' -import { IsomorphicRequest } from './IsomorphicRequest' -import { bufferFrom } from './interceptors/XMLHttpRequest/utils/bufferFrom' -import { InteractiveIsomorphicRequest } from './InteractiveIsomorphicRequest' +import { toInteractiveRequest } from './utils/toInteractiveRequest' +import { createRequestWithCredentials } from './utils/RequestWithCredentials' + +export interface SerializedRequest { + id: string + url: string + method: string + headers: HeadersObject + credentials: RequestCredentials + body: string +} + +interface RevivedRequest extends Omit { + url: URL + headers: Headers +} + +export interface SerializedResponse { + status: number + statusText: string + headers: HeadersObject + body: string +} export class RemoteHttpInterceptor extends BatchInterceptor< [ClientRequestInterceptor, XMLHttpRequestInterceptor] @@ -28,10 +48,19 @@ export class RemoteHttpInterceptor extends BatchInterceptor< let handleParentMessage: NodeJS.MessageListener - this.on('request', async (request) => { + this.on('request', async (request, requestId) => { // Send the stringified intercepted request to // the parent process where the remote resolver is established. - const serializedRequest = JSON.stringify(request) + const serializedRequest = JSON.stringify({ + id: requestId, + method: request.method, + url: request.url, + headers: headersToObject(request.headers), + credentials: request.credentials, + body: ['GET', 'HEAD'].includes(request.method) + ? null + : await request.text(), + } as SerializedRequest) this.log('sent serialized request to the child:', serializedRequest) process.send?.(`request:${serializedRequest}`) @@ -42,7 +71,7 @@ export class RemoteHttpInterceptor extends BatchInterceptor< return resolve() } - if (message.startsWith(`response:${request.id}`)) { + if (message.startsWith(`response:${requestId}`)) { const [, serializedResponse] = message.match(/^response:.+?:(.+)$/) || [] @@ -50,7 +79,16 @@ export class RemoteHttpInterceptor extends BatchInterceptor< return resolve() } - const mockedResponse = JSON.parse(serializedResponse) + const responseInit = JSON.parse( + serializedResponse + ) as SerializedResponse + + const mockedResponse = new Response(responseInit.body, { + status: responseInit.status, + statusText: responseInit.statusText, + headers: new Headers(responseInit.headers), + }) + request.respondWith(mockedResponse) resolve() } @@ -111,36 +149,49 @@ export class RemoteHttpResolver extends Interceptor { } const [, serializedRequest] = message.match(/^request:(.+)$/) || [] - if (!serializedRequest) { return } - const requestJson = JSON.parse(serializedRequest, requestReviver) + const requestJson = JSON.parse( + serializedRequest, + requestReviver + ) as RevivedRequest log('parsed intercepted request', requestJson) - const body = bufferFrom(requestJson.body) - - const isomorphicRequest = new IsomorphicRequest(requestJson.url, { - ...requestJson, - body: body.buffer, + const capturedRequest = createRequestWithCredentials(requestJson.url, { + method: requestJson.method, + headers: new Headers(requestJson.headers), + credentials: requestJson.credentials, + body: requestJson.body, }) - const interactiveIsomorphicRequest = new InteractiveIsomorphicRequest( - isomorphicRequest + const interactiveRequest = toInteractiveRequest(capturedRequest) + + this.emitter.emit('request', interactiveRequest, requestJson.id) + await this.emitter.untilIdle( + 'request', + ({ args: [, pendingRequestId] }) => { + return pendingRequestId === requestJson.id + } ) + const [mockedResponse] = await interactiveRequest.respondWith.invoked() - this.emitter.emit('request', interactiveIsomorphicRequest) - await this.emitter.untilIdle('request', ({ args: [request] }) => { - return request.id === interactiveIsomorphicRequest.id - }) - const [mockedResponse] = - await interactiveIsomorphicRequest.respondWith.invoked() + if (!mockedResponse) { + return + } log('event.respondWith called with:', mockedResponse) + const responseClone = mockedResponse.clone() + const responseText = await mockedResponse.text() // Send the mocked response to the child process. - const serializedResponse = JSON.stringify(mockedResponse) + const serializedResponse = JSON.stringify({ + status: mockedResponse.status, + statusText: mockedResponse.statusText, + headers: headersToObject(mockedResponse.headers), + body: responseText, + } as SerializedResponse) this.process.send( `response:${requestJson.id}:${serializedResponse}`, @@ -149,15 +200,14 @@ export class RemoteHttpResolver extends Interceptor { return } - if (mockedResponse) { - // Emit an optimistic "response" event at this point, - // not to rely on the back-and-forth signaling for the sake of the event. - this.emitter.emit( - 'response', - isomorphicRequest, - toIsoResponse(mockedResponse) - ) - } + // Emit an optimistic "response" event at this point, + // not to rely on the back-and-forth signaling for the sake of the event. + this.emitter.emit( + 'response', + responseClone, + capturedRequest, + requestJson.id + ) } ) diff --git a/src/glossary.ts b/src/glossary.ts index 6495e41d..93cd6710 100644 --- a/src/glossary.ts +++ b/src/glossary.ts @@ -1,27 +1,14 @@ -import type { HeadersObject, Headers } from 'headers-polyfill' -import type { InteractiveIsomorphicRequest } from './InteractiveIsomorphicRequest' -import type { IsomorphicRequest } from './IsomorphicRequest' +import type { InteractiveRequest } from './utils/toInteractiveRequest' export const IS_PATCHED_MODULE: unique symbol = Symbol('isPatchedModule') export type RequestCredentials = 'omit' | 'include' | 'same-origin' -export interface IsomorphicResponse { - status: number - statusText: string - headers: Headers - body?: string -} - -export interface MockedResponse - extends Omit, 'headers'> { - headers?: HeadersObject -} - export type HttpRequestEventMap = { - request(request: InteractiveIsomorphicRequest): Promise | void + request(request: InteractiveRequest, requestId: string): Promise | void response( - request: IsomorphicRequest, - response: IsomorphicResponse + response: Response, + request: Request, + requestId: string ): Promise | void } diff --git a/src/index.ts b/src/index.ts index 537d1f7f..e29b76d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,6 @@ export * from './glossary' export * from './Interceptor' export * from './BatchInterceptor' -export * from './IsomorphicRequest' -export * from './InteractiveIsomorphicRequest' /* Utils */ export { getCleanUrl } from './utils/getCleanUrl' diff --git a/src/interceptors/ClientRequest/NodeClientRequest.test.ts b/src/interceptors/ClientRequest/NodeClientRequest.test.ts index 06b2f3d2..ab252930 100644 --- a/src/interceptors/ClientRequest/NodeClientRequest.test.ts +++ b/src/interceptors/ClientRequest/NodeClientRequest.test.ts @@ -4,6 +4,7 @@ import { debug } from 'debug' import * as express from 'express' import { HttpServer } from '@open-draft/test-server/http' +import { Response } from '@remix-run/web-fetch' import { NodeClientRequest } from './NodeClientRequest' import { getIncomingMessageBody } from './utils/getIncomingMessageBody' import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs' @@ -49,13 +50,14 @@ test('gracefully finishes the request when it has a mocked response', (done) => ) emitter.on('request', (request) => { - request.respondWith({ - status: 301, - headers: { - 'x-custom-header': 'yes', - }, - body: 'mocked-response', - }) + request.respondWith( + new Response('mocked-response', { + status: 301, + headers: { + 'x-custom-header': 'yes', + }, + }) + ) }) request.on('response', async (response) => { @@ -88,10 +90,7 @@ test('responds with a mocked response when requesting an existing hostname', (do ) emitter.on('request', (request) => { - request.respondWith({ - status: 201, - body: 'mocked-response', - }) + request.respondWith(new Response('mocked-response', { status: 201 })) }) request.on('response', async (response) => { @@ -183,10 +182,9 @@ test('does not emit ENOTFOUND error connecting to an inactive server given mocke emitter.on('request', async (request) => { await sleep(250) - request.respondWith({ - status: 200, - statusText: 'Works', - }) + request.respondWith( + new Response(null, { status: 200, statusText: 'Works' }) + ) }) request.on('error', handleError) @@ -212,10 +210,9 @@ test('does not emit ECONNREFUSED error connecting to an inactive server given mo emitter.on('request', async (request) => { await sleep(250) - request.respondWith({ - status: 200, - statusText: 'Works', - }) + request.respondWith( + new Response(null, { status: 200, statusText: 'Works' }) + ) }) request.on('error', handleError) @@ -272,10 +269,7 @@ test('does not send request body to the original server given mocked response', emitter.on('request', async (request) => { await sleep(200) - request.respondWith({ - status: 301, - body: 'mock created!', - }) + request.respondWith(new Response('mock created!', { status: 301 })) }) request.write('one') diff --git a/src/interceptors/ClientRequest/NodeClientRequest.ts b/src/interceptors/ClientRequest/NodeClientRequest.ts index 88279735..029539fd 100644 --- a/src/interceptors/ClientRequest/NodeClientRequest.ts +++ b/src/interceptors/ClientRequest/NodeClientRequest.ts @@ -1,27 +1,22 @@ import type { Debugger } from 'debug' -import type { RequestOptions } from 'http' import { ClientRequest, IncomingMessage } from 'http' import { until } from '@open-draft/until' -import { Headers, objectToHeaders } from 'headers-polyfill' -import { MockedResponse } from '../../glossary' import type { ClientRequestEmitter } from '.' -import { concatChunkToBuffer } from './utils/concatChunkToBuffer' import { + ClientRequestEndCallback, ClientRequestEndChunk, normalizeClientRequestEndArgs, } from './utils/normalizeClientRequestEndArgs' import { NormalizedClientRequestArgs } from './utils/normalizeClientRequestArgs' -import { toIsoResponse } from '../../utils/toIsoResponse' -import { getIncomingMessageBody } from './utils/getIncomingMessageBody' -import { bodyBufferToString } from './utils/bodyBufferToString' import { ClientRequestWriteArgs, normalizeClientRequestWriteArgs, } from './utils/normalizeClientRequestWriteArgs' import { cloneIncomingMessage } from './utils/cloneIncomingMessage' -import { IsomorphicRequest } from '../../IsomorphicRequest' -import { InteractiveIsomorphicRequest } from '../../InteractiveIsomorphicRequest' -import { getArrayBuffer } from '../../utils/bufferUtils' +import { createResponse } from './utils/createResponse' +import { createRequest } from './utils/createRequest' +import { toInteractiveRequest } from '../../utils/toInteractiveRequest' +import { uuidv4 } from '../../utils/uuid' export type Protocol = 'http' | 'https' @@ -42,8 +37,6 @@ export class NodeClientRequest extends ClientRequest { 'EAI_AGAIN', ] - private url: URL - private options: RequestOptions private response: IncomingMessage private emitter: ClientRequestEmitter private log: Debugger @@ -54,7 +47,8 @@ export class NodeClientRequest extends ClientRequest { private responseSource: 'mock' | 'bypass' = 'mock' private capturedError?: NodeJS.ErrnoException - public requestBody: Buffer[] = [] + public url: URL + public requestBuffer: Buffer | null constructor( [url, requestOptions, callback]: NormalizedClientRequestArgs, @@ -73,19 +67,44 @@ export class NodeClientRequest extends ClientRequest { }) this.url = url - this.options = requestOptions this.emitter = options.emitter + // Set request buffer to null by default so that GET/HEAD requests + // without a body wouldn't suddenly get one. + this.requestBuffer = null + // Construct a mocked response message. this.response = new IncomingMessage(this.socket!) } + private writeRequestBodyChunk( + chunk: string | Buffer | null, + encoding?: BufferEncoding + ): void { + if (chunk == null) { + return + } + + if (this.requestBuffer == null) { + this.requestBuffer = Buffer.from([]) + } + + const resolvedChunk = Buffer.isBuffer(chunk) + ? chunk + : Buffer.from(chunk, encoding) + + this.requestBuffer = Buffer.concat([this.requestBuffer, resolvedChunk]) + } + write(...args: ClientRequestWriteArgs): boolean { const [chunk, encoding, callback] = normalizeClientRequestWriteArgs(args) this.log('write:', { chunk, encoding, callback }) this.chunks.push({ chunk, encoding }) - this.requestBody = concatChunkToBuffer(chunk, this.requestBody) - this.log('chunk successfully stored!', this.requestBody) + + // Write each request body chunk to the internal buffer. + this.writeRequestBodyChunk(chunk, encoding) + + this.log('chunk successfully stored!', this.requestBuffer?.byteLength) /** * Prevent invoking the callback if the written chunk is empty. @@ -106,14 +125,25 @@ export class NodeClientRequest extends ClientRequest { end(...args: any): this { this.log('end', args) + const requestId = uuidv4() + const [chunk, encoding, callback] = normalizeClientRequestEndArgs(...args) this.log('normalized arguments:', { chunk, encoding, callback }) - const requestBody = this.getRequestBody(chunk) - const isomorphicRequest = this.toIsomorphicRequest(requestBody) - const interactiveIsomorphicRequest = new InteractiveIsomorphicRequest( - isomorphicRequest - ) + // Write the last request body chunk passed to the "end()" method. + this.writeRequestBodyChunk(chunk, encoding || undefined) + + const capturedRequest = createRequest(this) + const interactiveRequest = toInteractiveRequest(capturedRequest) + + // Prevent handling this request if it has already been handled + // in another (parent) interceptor (like XMLHttpRequest -> ClientRequest). + // That means some interceptor up the chain has concluded that + // this request must be performed as-is. + if (this.getHeader('X-Request-Id') != null) { + this.removeHeader('X-Request-Id') + return this.passthrough(chunk, encoding, callback) + } // Notify the interceptor about the request. // This will call any "request" listeners the users have. @@ -121,23 +151,25 @@ export class NodeClientRequest extends ClientRequest { 'emitting the "request" event for %d listener(s)...', this.emitter.listenerCount('request') ) - this.emitter.emit('request', interactiveIsomorphicRequest) + this.emitter.emit('request', interactiveRequest, requestId) // Execute the resolver Promise like a side-effect. // Node.js 16 forces "ClientRequest.end" to be synchronous and return "this". until(async () => { - await this.emitter.untilIdle('request', ({ args: [request] }) => { - /** - * @note Await only those listeners that are relevant to this request. - * This prevents extraneous parallel request from blocking the resolution - * of another, unrelated request. For example, during response patching, - * when request resolution is nested. - */ - return request.id === interactiveIsomorphicRequest.id - }) + await this.emitter.untilIdle( + 'request', + ({ args: [, pendingRequestId] }) => { + /** + * @note Await only those listeners that are relevant to this request. + * This prevents extraneous parallel request from blocking the resolution + * of another, unrelated request. For example, during response patching, + * when request resolution is nested. + */ + return pendingRequestId === requestId + } + ) - const [mockedResponse] = - await interactiveIsomorphicRequest.respondWith.invoked() + const [mockedResponse] = await interactiveRequest.respondWith.invoked() this.log('event.respondWith called with:', mockedResponse) return mockedResponse @@ -156,96 +188,54 @@ export class NodeClientRequest extends ClientRequest { return this } + // Forward any request headers that the "request" listener + // may have modified before proceeding with this request. + for (const [headerName, headerValue] of capturedRequest.headers) { + this.setHeader(headerName, headerValue) + } + if (mockedResponse) { + const responseClone = mockedResponse.clone() + this.log('received mocked response:', mockedResponse) this.responseSource = 'mock' - const isomorphicResponse = toIsoResponse(mockedResponse) this.respondWith(mockedResponse) - this.log( - isomorphicResponse.status, - isomorphicResponse.statusText, - isomorphicResponse.body, - '(MOCKED)' - ) + this.log(mockedResponse.status, mockedResponse.statusText, '(MOCKED)') callback?.() this.log('emitting the custom "response" event...') + this.emitter.emit('response', responseClone, capturedRequest, requestId) - this.emitter.emit('response', isomorphicRequest, isomorphicResponse) + this.log('request (mock) is completed') return this } this.log('no mocked response received!') - // Set the response source to "bypass". - // Any errors emitted past this point are not suppressed. - this.responseSource = 'bypass' - - // Propagate previously captured errors. - // For example, a ECONNREFUSED error when connecting to a non-existing host. - if (this.capturedError) { - this.emit('error', this.capturedError) - return this - } - - // Write the request body chunks in the order of ".write()" calls. - // Note that no request body has been written prior to this point - // in order to prevent the Socket to communicate with a potentially - // existing server. - this.log('writing request chunks...', this.chunks) - - for (const { chunk, encoding } of this.chunks) { - if (encoding) { - super.write(chunk, encoding) - } else { - super.write(chunk) - } - } - - this.once('error', (error) => { - this.log('original request error:', error) - }) - - this.once('abort', () => { - this.log('original request aborted!') - }) - - this.once('response-internal', async (response: IncomingMessage) => { - const responseBody = await getIncomingMessageBody(response) - this.log(response.statusCode, response.statusMessage, responseBody) - this.log('original response headers:', response.headers) + this.once('response-internal', (message: IncomingMessage) => { + this.log(message.statusCode, message.statusMessage) + this.log('original response headers:', message.headers) this.log('emitting the custom "response" event...') - this.emitter.emit('response', isomorphicRequest, { - status: response.statusCode || 200, - statusText: response.statusMessage || 'OK', - headers: objectToHeaders(response.headers), - body: responseBody, - }) + this.emitter.emit( + 'response', + createResponse(message), + capturedRequest, + requestId + ) }) - this.log('performing original request...') - - return super.end( - ...[ - chunk, - encoding as any, - () => { - this.log('original request end!') - callback?.() - }, - ].filter(Boolean) - ) + return this.passthrough(chunk, encoding, callback) }) return this } emit(event: string, ...data: any[]) { - this.log('event:%s', event) + this.log('emit: %s', event) if (event === 'response') { this.log('found "response" event, cloning the response...') @@ -299,7 +289,65 @@ export class NodeClientRequest extends ClientRequest { return super.emit(event, ...data) } - private respondWith(mockedResponse: MockedResponse): void { + /** + * Performs the intercepted request as-is. + * Replays the captured request body chunks, + * still emits the internal events, and wraps + * up the request with `super.end()`. + */ + private passthrough( + chunk: ClientRequestEndChunk | null, + encoding?: BufferEncoding | null, + callback?: ClientRequestEndCallback | null + ): this { + // Set the response source to "bypass". + // Any errors emitted past this point are not suppressed. + this.responseSource = 'bypass' + + // Propagate previously captured errors. + // For example, a ECONNREFUSED error when connecting to a non-existing host. + if (this.capturedError) { + this.emit('error', this.capturedError) + return this + } + + this.log('writing request chunks...', this.chunks) + + // Write the request body chunks in the order of ".write()" calls. + // Note that no request body has been written prior to this point + // in order to prevent the Socket to communicate with a potentially + // existing server. + for (const { chunk, encoding } of this.chunks) { + if (encoding) { + super.write(chunk, encoding) + } else { + super.write(chunk) + } + } + + this.once('error', (error) => { + this.log('original request error:', error) + }) + + this.once('abort', () => { + this.log('original request aborted!') + }) + + this.once('response-internal', (message: IncomingMessage) => { + this.log(message.statusCode, message.statusMessage) + this.log('original response headers:', message.headers) + }) + + this.log('performing original request...') + + // This call signature is way too dynamic. + return super.end(...[chunk, encoding as any, callback].filter(Boolean)) + } + + /** + * Responds to this request instance using a mocked response. + */ + private respondWith(mockedResponse: Response): void { this.log('responding with a mocked response...', mockedResponse) const { status, statusText, headers, body } = mockedResponse @@ -309,29 +357,48 @@ export class NodeClientRequest extends ClientRequest { if (headers) { this.response.headers = {} - for (const [headerName, headerValue] of Object.entries(headers)) { - this.response.rawHeaders.push( - headerName, - ...(Array.isArray(headerValue) ? headerValue : [headerValue]) - ) + headers.forEach((headerValue, headerName) => { + /** + * @note Make sure that multi-value headers are appended correctly. + */ + this.response.rawHeaders.push(headerName, headerValue) const insensitiveHeaderName = headerName.toLowerCase() const prevHeaders = this.response.headers[insensitiveHeaderName] this.response.headers[insensitiveHeaderName] = prevHeaders ? Array.prototype.concat([], prevHeaders, headerValue) : headerValue - } + }) } this.log('mocked response headers ready:', headers) - if (body) { - this.response.push(Buffer.from(body)) + const closeResponseStream = () => { + // Push "null" to indicate that the response body is complete + // and shouldn't be written to anymore. + this.response.push(null) + this.response.complete = true } - // Push "null" to indicate that the response body is complete - // and shouldn't be written to anymore. - this.response.push(null) - this.response.complete = true + if (body) { + const bodyReader = body.getReader() + const readNextChunk = async (): Promise => { + const { done, value } = await bodyReader.read() + + if (done) { + closeResponseStream() + return + } + + // this.response.push(Buffer.from(body)) + this.response.push(value) + + return readNextChunk() + } + + readNextChunk() + } else { + closeResponseStream() + } /** * Set the internal "res" property to the mocked "OutgoingMessage" @@ -360,47 +427,4 @@ export class NodeClientRequest extends ClientRequest { // @ts-ignore this.agent.destroy() } - - private getRequestBody(chunk: ClientRequestEndChunk | null): ArrayBuffer { - const writtenRequestBody = bodyBufferToString( - Buffer.concat(this.requestBody) - ) - this.log('written request body:', writtenRequestBody) - - // Write the last request body chunk to the internal request body buffer. - if (chunk) { - this.requestBody = concatChunkToBuffer(chunk, this.requestBody) - } - - const resolvedRequestBody = Buffer.concat(this.requestBody) - this.log('resolved request body:', resolvedRequestBody) - - return getArrayBuffer(resolvedRequestBody) - } - - private toIsomorphicRequest(body: ArrayBuffer): IsomorphicRequest { - this.log('creating isomorphic request object...') - - const outgoingHeaders = this.getHeaders() - this.log('request outgoing headers:', outgoingHeaders) - - const headers = new Headers() - for (const [headerName, headerValue] of Object.entries(outgoingHeaders)) { - if (!headerValue) { - continue - } - - headers.set(headerName.toLowerCase(), headerValue.toString()) - } - - const isomorphicRequest = new IsomorphicRequest(this.url, { - body, - method: this.options.method || 'GET', - credentials: 'same-origin', - headers, - }) - - this.log('successfully created isomorphic request!', isomorphicRequest) - return isomorphicRequest - } } diff --git a/src/interceptors/ClientRequest/index.test.ts b/src/interceptors/ClientRequest/index.test.ts index 1112b665..11aa3309 100644 --- a/src/interceptors/ClientRequest/index.test.ts +++ b/src/interceptors/ClientRequest/index.test.ts @@ -1,5 +1,6 @@ import * as http from 'http' import { HttpServer } from '@open-draft/test-server/http' +import { Response } from '@remix-run/web-fetch' import { ClientRequestInterceptor } from '.' const httpServer = new HttpServer((app) => { @@ -15,7 +16,6 @@ const interceptor = new ClientRequestInterceptor() beforeAll(async () => { await httpServer.listen() - interceptor.apply() }) @@ -28,11 +28,13 @@ it('forbids calling "respondWith" multiple times for the same request', (done) = const requestUrl = httpServer.http.url('/') interceptor.on('request', (request) => { - request.respondWith({ status: 200 }) + request.respondWith(new Response()) }) interceptor.on('request', (request) => { - expect(() => request.respondWith({ status: 301 })).toThrow( + expect(() => + request.respondWith(new Response(null, { status: 301 })) + ).toThrow( `Failed to respond to "GET ${requestUrl}" request: the "request" event has already been responded to.` ) diff --git a/src/interceptors/ClientRequest/index.ts b/src/interceptors/ClientRequest/index.ts index f66aadbe..c1572cae 100644 --- a/src/interceptors/ClientRequest/index.ts +++ b/src/interceptors/ClientRequest/index.ts @@ -1,23 +1,15 @@ import http from 'http' import https from 'https' -import { invariant } from 'outvariant' -import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary' +import { HttpRequestEventMap } from '../../glossary' import { Interceptor } from '../../Interceptor' import { AsyncEventEmitter } from '../../utils/AsyncEventEmitter' import { get } from './http.get' import { request } from './http.request' import { NodeClientOptions, Protocol } from './NodeClientRequest' -export type MaybePatchedModule = Module & { - [IS_PATCHED_MODULE]?: boolean -} - export type ClientRequestEmitter = AsyncEventEmitter -export type ClientRequestModules = Map< - Protocol, - MaybePatchedModule | MaybePatchedModule -> +export type ClientRequestModules = Map /** * Intercept requests made via the `ClientRequest` class. @@ -41,17 +33,7 @@ export class ClientRequestInterceptor extends Interceptor { for (const [protocol, requestModule] of this.modules) { const { request: pureRequest, get: pureGet } = requestModule - invariant( - !requestModule[IS_PATCHED_MODULE], - 'Failed to patch the "%s" module: already patched.', - protocol - ) - this.subscriptions.push(() => { - Object.defineProperty(requestModule, IS_PATCHED_MODULE, { - value: undefined, - }) - requestModule.request = pureRequest requestModule.get = pureGet @@ -73,12 +55,6 @@ export class ClientRequestInterceptor extends Interceptor { // Force a line break. get(protocol, options) - Object.defineProperty(requestModule, IS_PATCHED_MODULE, { - configurable: true, - enumerable: true, - value: true, - }) - log('native "%s" module patched!', protocol) } } diff --git a/src/interceptors/ClientRequest/utils/bodyBufferToString.test.ts b/src/interceptors/ClientRequest/utils/bodyBufferToString.test.ts deleted file mode 100644 index a34fcdce..00000000 --- a/src/interceptors/ClientRequest/utils/bodyBufferToString.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { bodyBufferToString } from './bodyBufferToString' - -test('given a utf8 buffer returns utf8 string', () => { - const utfBuffer = Buffer.from('one') - expect(bodyBufferToString(utfBuffer)).toEqual('one') -}) - -test('given a hex buffer returns a hex buffer', () => { - const hexBuffer = Buffer.from('7468697320697320612074c3a97374', 'hex') - expect(bodyBufferToString(hexBuffer)).toEqual('this is a tést') -}) - -test('given a non-utf buffer returns a hex buffer', () => { - const anyBuffer = Buffer.from('tést', 'latin1') - expect(bodyBufferToString(anyBuffer)).toEqual('74e97374') -}) diff --git a/src/interceptors/ClientRequest/utils/bodyBufferToString.ts b/src/interceptors/ClientRequest/utils/bodyBufferToString.ts deleted file mode 100644 index daf07bdb..00000000 --- a/src/interceptors/ClientRequest/utils/bodyBufferToString.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function bodyBufferToString(buffer: Buffer): string { - const utfEncodedBuffer = buffer.toString('utf8') - const bufferCopy = Buffer.from(utfEncodedBuffer) - const isUtf8 = bufferCopy.equals(buffer) - - return isUtf8 ? utfEncodedBuffer : buffer.toString('hex') -} diff --git a/src/interceptors/ClientRequest/utils/concatChunkToBuffer.test.ts b/src/interceptors/ClientRequest/utils/concatChunkToBuffer.test.ts deleted file mode 100644 index b3985140..00000000 --- a/src/interceptors/ClientRequest/utils/concatChunkToBuffer.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { concatChunkToBuffer } from './concatChunkToBuffer' - -test('returns a concatenation result of two buffers', () => { - const nextBuffers = concatChunkToBuffer(Buffer.from('two'), [ - Buffer.from('one'), - ]) - expect(nextBuffers.map((buffer) => buffer.toString())).toEqual(['one', 'two']) -}) - -test('concatencates a given string to the buffer', () => { - const nextBuffers = concatChunkToBuffer('two', [Buffer.from('one')]) - expect(nextBuffers.map((buffer) => buffer.toString())).toEqual(['one', 'two']) -}) diff --git a/src/interceptors/ClientRequest/utils/concatChunkToBuffer.ts b/src/interceptors/ClientRequest/utils/concatChunkToBuffer.ts deleted file mode 100644 index 7aec111f..00000000 --- a/src/interceptors/ClientRequest/utils/concatChunkToBuffer.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function concatChunkToBuffer( - chunk: string | Buffer, - buffer: Buffer[] -): Buffer[] { - if (!Buffer.isBuffer(chunk)) { - chunk = Buffer.from(chunk) - } - - return buffer.concat(chunk) -} diff --git a/src/interceptors/ClientRequest/utils/createRequest.test.ts b/src/interceptors/ClientRequest/utils/createRequest.test.ts new file mode 100644 index 00000000..1354b8e4 --- /dev/null +++ b/src/interceptors/ClientRequest/utils/createRequest.test.ts @@ -0,0 +1,61 @@ +import { debug } from 'debug' +import { HttpRequestEventMap } from '../../..' +import { AsyncEventEmitter } from '../../../utils/AsyncEventEmitter' +import { NodeClientRequest } from '../NodeClientRequest' +import { createRequest } from './createRequest' + +const emitter = new AsyncEventEmitter() +const log = debug('test') + +it('creates a fetch Request with a JSON body', async () => { + const clientRequest = new NodeClientRequest( + [ + new URL('https://api.github.com'), + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + () => {}, + ], + { + emitter, + log, + } + ) + clientRequest.write(JSON.stringify({ firstName: 'John' })) + + const request = createRequest(clientRequest) + + expect(request.method).toBe('POST') + expect(request.url).toBe('https://api.github.com/') + expect(request.headers.get('Content-Type')).toBe('application/json') + expect(await request.json()).toEqual({ firstName: 'John' }) +}) + +it('creates a fetch Request with an empty body', async () => { + const clientRequest = new NodeClientRequest( + [ + new URL('https://api.github.com'), + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + () => {}, + ], + { + emitter, + log, + } + ) + + const request = createRequest(clientRequest) + + expect(request.method).toBe('GET') + expect(request.url).toBe('https://api.github.com/') + expect(request.headers.get('Accept')).toBe('application/json') + expect(request.body).toBe(null) +}) diff --git a/src/interceptors/ClientRequest/utils/createRequest.ts b/src/interceptors/ClientRequest/utils/createRequest.ts new file mode 100644 index 00000000..2758a05c --- /dev/null +++ b/src/interceptors/ClientRequest/utils/createRequest.ts @@ -0,0 +1,32 @@ +import type { Request } from '@remix-run/web-fetch' +import { Headers } from 'headers-polyfill' +import { createRequestWithCredentials } from '../../../utils/RequestWithCredentials' +import type { NodeClientRequest } from '../NodeClientRequest' + +/** + * Creates a Fetch API `Request` instance from the given `http.ClientRequest`. + */ +export function createRequest(clientRequest: NodeClientRequest): Request { + const headers = new Headers() + + const outgoingHeaders = clientRequest.getHeaders() + for (const headerName in outgoingHeaders) { + const headerValue = outgoingHeaders[headerName] + + if (!headerValue) { + continue + } + + const valuesList = Array.prototype.concat([], headerValue) + for (const value of valuesList) { + headers.append(headerName, value.toString()) + } + } + + return createRequestWithCredentials(clientRequest.url, { + method: clientRequest.method || 'GET', + headers, + credentials: 'same-origin', + body: clientRequest.requestBuffer, + }) +} diff --git a/src/interceptors/ClientRequest/utils/createResponse.test.ts b/src/interceptors/ClientRequest/utils/createResponse.test.ts new file mode 100644 index 00000000..d712426b --- /dev/null +++ b/src/interceptors/ClientRequest/utils/createResponse.test.ts @@ -0,0 +1,24 @@ +/** + * @jest-environment node + */ +import { Socket } from 'net' +import * as http from 'http' +import { createResponse } from './createResponse' + +it('creates a fetch api response from http incoming message', async () => { + const message = new http.IncomingMessage(new Socket()) + message.statusCode = 201 + message.statusMessage = 'Created' + message.headers['content-type'] = 'application/json' + + const response = createResponse(message) + + message.emit('data', Buffer.from('{"firstName":')) + message.emit('data', Buffer.from('"John"}')) + message.emit('end') + + expect(response.status).toBe(201) + expect(response.statusText).toBe('Created') + expect(response.headers.get('content-type')).toBe('application/json') + expect(await response.json()).toEqual({ firstName: 'John' }) +}) diff --git a/src/interceptors/ClientRequest/utils/createResponse.ts b/src/interceptors/ClientRequest/utils/createResponse.ts new file mode 100644 index 00000000..0c55c5e5 --- /dev/null +++ b/src/interceptors/ClientRequest/utils/createResponse.ts @@ -0,0 +1,22 @@ +import type { IncomingMessage } from 'http' +import { Response, ReadableStream } from '@remix-run/web-fetch' +import { objectToHeaders } from 'headers-polyfill' + +/** + * Creates a Fetch API `Response` instance from the given + * `http.IncomingMessage` instance. + */ +export function createResponse(message: IncomingMessage): Response { + const readable = new ReadableStream({ + start(controller) { + message.on('data', (chunk) => controller.enqueue(chunk)) + message.on('end', () => controller.close()) + }, + }) + + return new Response(readable, { + status: message.statusCode, + statusText: message.statusMessage, + headers: objectToHeaders(message.headers), + }) +} diff --git a/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.ts b/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.ts index 3ba2336a..661ce3f3 100644 --- a/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.ts +++ b/src/interceptors/ClientRequest/utils/normalizeClientRequestEndArgs.ts @@ -1,7 +1,7 @@ const debug = require('debug')('http normalizeClientRequestEndArgs') export type ClientRequestEndChunk = string | Buffer -type ClientRequestEndCallback = () => void +export type ClientRequestEndCallback = () => void type HttpRequestEndArgs = | [] diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestOverride.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestOverride.ts index e4fd0d0f..5d0c378f 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestOverride.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestOverride.ts @@ -4,21 +4,21 @@ */ import type { Debugger } from 'debug' import { until } from '@open-draft/until' -import { - Headers, - stringToHeaders, - objectToHeaders, - headersToString, -} from 'headers-polyfill' +import { Headers, stringToHeaders, headersToString } from 'headers-polyfill' import { DOMParser } from '@xmldom/xmldom' import { parseJson } from '../../utils/parseJson' -import { toIsoResponse } from '../../utils/toIsoResponse' -import { bufferFrom } from './utils/bufferFrom' import { createEvent } from './utils/createEvent' import type { XMLHttpRequestEmitter } from '.' -import { IsomorphicRequest } from '../../IsomorphicRequest' -import { encodeBuffer } from '../../utils/bufferUtils' -import { InteractiveIsomorphicRequest } from '../../InteractiveIsomorphicRequest' +import { + encodeBuffer, + decodeBuffer, + toArrayBuffer, +} from '../../utils/bufferUtils' +import { createResponse } from './utils/createResponse' +import { concatArrayBuffer } from './utils/concatArrayBuffer' +import { toInteractiveRequest } from '../../utils/toInteractiveRequest' +import { uuidv4 } from '../../utils/uuid' +import { createRequestWithCredentials } from '../../utils/RequestWithCredentials' type XMLHttpRequestEventHandler = ( this: XMLHttpRequest, @@ -55,6 +55,7 @@ export const createXMLHttpRequestOverride = ( return class XMLHttpRequestOverride implements XMLHttpRequest { _requestHeaders: Headers _responseHeaders: Headers + _responseBuffer: Uint8Array // Collection of events modified by `addEventListener`/`removeEventListener` calls. _events: XMLHttpRequestEvent[] = @@ -85,10 +86,7 @@ export const createXMLHttpRequestOverride = ( public user?: string public password?: string public async?: boolean - public response: any - public responseText: string public responseType: XMLHttpRequestResponseType - public responseXML: Document | null public responseURL: string public upload: XMLHttpRequestUpload public readyState: number @@ -129,17 +127,15 @@ export const createXMLHttpRequestOverride = ( this.method = 'GET' this.readyState = this.UNSENT this.withCredentials = false - this.status = 200 - this.statusText = 'OK' - this.response = '' + this.status = 0 + this.statusText = '' this.responseType = 'text' - this.responseText = '' - this.responseXML = null this.responseURL = '' this.upload = {} as any this.timeout = 0 this._requestHeaders = new Headers() + this._responseBuffer = new Uint8Array() this._responseHeaders = new Headers() } @@ -169,7 +165,6 @@ export const createXMLHttpRequestOverride = ( this.log('trigger "%s" (%d)', eventName, this.readyState) this.log('resolve listener for event "%s"', eventName) - // @ts-expect-error XMLHttpRequest class has no index signature. const callback = this[`on${eventName}`] as XMLHttpRequestEventHandler callback?.call(this, createEvent(this, eventName, options)) @@ -191,12 +186,10 @@ export const createXMLHttpRequestOverride = ( this.log('reset') this.setReadyState(this.UNSENT) - this.status = 200 - this.statusText = 'OK' - this.response = null as any - this.responseText = null as any - this.responseXML = null as any + this.status = 0 + this.statusText = '' + this._responseBuffer = new Uint8Array() this._requestHeaders = new Headers() this._responseHeaders = new Headers() } @@ -228,12 +221,9 @@ export const createXMLHttpRequestOverride = ( public send(data?: string | ArrayBuffer) { this.log('send %s %s', this.method, this.url) - let buffer: ArrayBuffer - if (typeof data === 'string') { - buffer = encodeBuffer(data) - } else { - buffer = data || new ArrayBuffer(0) - } + + const requestBuffer: ArrayBuffer | undefined = + typeof data === 'string' ? encodeBuffer(data) : data let url: URL @@ -249,34 +239,36 @@ export const createXMLHttpRequestOverride = ( this.log('request headers', this._requestHeaders) // Create an intercepted request instance exposed to the request intercepting middleware. - const isomorphicRequest = new IsomorphicRequest(url, { - body: buffer, + const requestId = uuidv4() + const capturedRequest = createRequestWithCredentials(url, { method: this.method, headers: this._requestHeaders, credentials: this.withCredentials ? 'include' : 'omit', + body: requestBuffer, }) - const interactiveIsomorphicRequest = new InteractiveIsomorphicRequest( - isomorphicRequest - ) + const interactiveRequest = toInteractiveRequest(capturedRequest) this.log( 'emitting the "request" event for %d listener(s)...', emitter.listenerCount('request') ) - emitter.emit('request', interactiveIsomorphicRequest) + emitter.emit('request', interactiveRequest, requestId) this.log('awaiting mocked response...') Promise.resolve( until(async () => { - await emitter.untilIdle('request', ({ args: [request] }) => { - return request.id === interactiveIsomorphicRequest.id - }) + await emitter.untilIdle( + 'request', + ({ args: [, pendingRequestId] }) => { + return pendingRequestId === requestId + } + ) this.log('all request listeners have been resolved!') const [mockedResponse] = - await interactiveIsomorphicRequest.respondWith.invoked() + await interactiveRequest.respondWith.invoked() this.log('event.respondWith called with:', mockedResponse) return mockedResponse @@ -290,72 +282,108 @@ export const createXMLHttpRequestOverride = ( middlewareException ) + // Mark the request as complete. + this.setReadyState(this.DONE) + // No way to propagate the actual error message. this.trigger('error') - this.abort() + + // Emit the "loadend" event to notify that the request has settled. + // In this case, there's been an error with the request so + // we must not emit the "load" event. + this.trigger('loadend') + + // Abort must not be called when request fails! + // this.abort() return } + // Forward request headers modified in the "request" listener. + this._requestHeaders = new Headers(capturedRequest.headers) + // Return a mocked response, if provided in the middleware. if (mockedResponse) { + const responseClone = mockedResponse.clone() this.log('received mocked response', mockedResponse) - // Trigger a loadstart event to indicate the initialization of the fetch. - this.trigger('loadstart') - this.status = mockedResponse.status ?? 200 this.statusText = mockedResponse.statusText || 'OK' - this._responseHeaders = mockedResponse.headers - ? objectToHeaders(mockedResponse.headers) - : new Headers() - this.log('set response status', this.status, this.statusText) + + this._responseHeaders = new Headers(mockedResponse.headers || {}) this.log('set response headers', this._responseHeaders) + this.log('response type', this.responseType) + this.responseURL = this.url + + const totalLength = this._responseHeaders.has('Content-Length') + ? Number(this._responseHeaders.get('Content-Length')) + : undefined + + // Trigger a loadstart event to indicate the initialization of the fetch. + this.trigger('loadstart', { loaded: 0, total: totalLength }) + // Mark that response headers has been received // and trigger a ready state event to reflect received headers - // in a custom `onreadystatechange` callback. + // in a custom "onreadystatechange" callback. this.setReadyState(this.HEADERS_RECEIVED) - this.log('response type', this.responseType) - this.response = this.getResponseBody(mockedResponse.body) - this.responseURL = this.url - this.responseText = mockedResponse.body || '' - this.responseXML = this.getResponseXML() + this.setReadyState(this.LOADING) - this.log('set response body', this.response) - - if (mockedResponse.body && this.response) { - this.setReadyState(this.LOADING) + const closeResponseStream = () => { + /** + * Explicitly mark the request as done so its response never hangs. + * @see https://github.com/mswjs/interceptors/issues/13 + */ + this.setReadyState(this.DONE) - // Presence of the mocked response implies a response body (not null). - // Presence of the coerced `this.response` implies the mocked body is valid. - const bodyBuffer = bufferFrom(mockedResponse.body) + // Always trigger the "load" event because at this point + // the request has been performed successfully. + this.trigger('load', { + loaded: this._responseBuffer.byteLength, + total: totalLength, + }) - // Trigger a progress event based on the mocked response body. - this.trigger('progress', { - loaded: bodyBuffer.length, - total: bodyBuffer.length, + // Trigger a loadend event to indicate the fetch has completed. + this.trigger('loadend', { + loaded: this._responseBuffer.byteLength, + total: totalLength, }) + + emitter.emit('response', responseClone, capturedRequest, requestId) } - /** - * Explicitly mark the request as done so its response never hangs. - * @see https://github.com/mswjs/interceptors/issues/13 - */ - this.setReadyState(this.DONE) + if (mockedResponse.body) { + const reader = mockedResponse.body.getReader() - // Trigger a load event to indicate the fetch has succeeded. - this.trigger('load') - // Trigger a loadend event to indicate the fetch has completed. - this.trigger('loadend') + const readNextChunk = async (): Promise => { + const { value, done } = await reader.read() - emitter.emit( - 'response', - isomorphicRequest, - toIsoResponse(mockedResponse) - ) + if (done) { + closeResponseStream() + return + } + + if (value) { + this._responseBuffer = concatArrayBuffer( + this._responseBuffer, + value + ) + + this.trigger('progress', { + loaded: this._responseBuffer.byteLength, + total: totalLength, + }) + } + + readNextChunk() + } + + readNextChunk() + } else { + closeResponseStream() + } } else { this.log('no mocked response received!') @@ -371,72 +399,148 @@ export const createXMLHttpRequestOverride = ( this.password ) - // Reflect a successful state of the original request - // on the patched instance. - originalRequest.addEventListener('load', () => { - this.log('original "onload"') + originalRequest.addEventListener('readystatechange', () => { + // Forward the original response headers to the patched instance + // immediately as they are received. + if ( + originalRequest.readyState === XMLHttpRequest.HEADERS_RECEIVED + ) { + const responseHeaders = originalRequest.getAllResponseHeaders() + this.log('original response headers:\n', responseHeaders) + + this._responseHeaders = stringToHeaders(responseHeaders) + this.log( + 'original response headers (normalized)', + this._responseHeaders + ) + } + }) + + originalRequest.addEventListener('loadstart', () => { + // Forward the response type to the patched instance immediately. + // Response type affects how response reading properties are resolved. + this.responseType = originalRequest.responseType + }) + + originalRequest.addEventListener('progress', () => { + this._responseBuffer = concatArrayBuffer( + this._responseBuffer, + encodeBuffer(originalRequest.responseText) + ) + }) + + // Update the patched instance on the "loadend" event + // because it fires when the request settles (succeeds/errors). + originalRequest.addEventListener('loadend', () => { + this.log('original "loadend"') this.status = originalRequest.status this.statusText = originalRequest.statusText this.responseURL = originalRequest.responseURL - this.responseType = originalRequest.responseType - this.response = originalRequest.response - this.responseText = originalRequest.responseText - this.responseXML = originalRequest.responseXML - - this.log('set mock request readyState to DONE') + this.log('received original response', this.status, this.statusText) // Explicitly mark the mocked request instance as done // so the response never hangs. - /** - * @note `readystatechange` listener is called TWICE - * in the case of unhandled request. - */ this.setReadyState(this.DONE) + this.log('set mock request readyState to DONE') - this.log('received original response', this.status, this.statusText) this.log('original response body:', this.response) + this.log('original response finished!') - const responseHeaders = originalRequest.getAllResponseHeaders() - this.log('original response headers:\n', responseHeaders) - - this._responseHeaders = stringToHeaders(responseHeaders) - this.log( - 'original response headers (normalized)', - this._responseHeaders + emitter.emit( + 'response', + createResponse(originalRequest, this._responseBuffer), + capturedRequest, + requestId ) - - this.log('original response finished') - - emitter.emit('response', isomorphicRequest, { - status: originalRequest.status, - statusText: originalRequest.statusText, - headers: this._responseHeaders, - body: originalRequest.response, - }) }) + this.propagateHeaders(originalRequest, this._requestHeaders) + // Assign callbacks and event listeners from the intercepted XHR instance // to the original XHR instance. this.propagateCallbacks(originalRequest) this.propagateListeners(originalRequest) - this.propagateHeaders(originalRequest, this._requestHeaders) if (this.async) { originalRequest.timeout = this.timeout } + /** + * @note Set the intercepted request ID on the original request + * so that if it triggers any other interceptors, they don't attempt + * to process it once again. This happens when bypassing XMLHttpRequest + * because it's polyfilled with "http.ClientRequest" in JSDOM. + */ + originalRequest.setRequestHeader('X-Request-Id', requestId) + this.log('send', data) originalRequest.send(data) } }) } + public get responseText(): string { + this.log('responseText()') + + const encoding = this.getResponseHeader('Content-Encoding') as + | BufferEncoding + | undefined + + return decodeBuffer(this._responseBuffer, encoding || undefined) + } + + public get response(): unknown { + switch (this.responseType) { + case 'json': { + this.log('resolving response body as JSON') + return parseJson(this.responseText) + } + + case 'arraybuffer': { + this.log('resolving response body as ArrayBuffer') + return toArrayBuffer(this._responseBuffer) + } + + case 'blob': { + const mimeType = + this.getResponseHeader('content-type') || 'text/plain' + this.log('resolving response body as blog (%s)', mimeType) + return new Blob([this.responseText], { type: mimeType }) + } + + case 'document': { + this.log('resolving response body as XML') + return this.responseXML + } + + default: { + return this.responseText + } + } + } + + public get responseXML(): Document | null { + const contentType = this.getResponseHeader('content-type') || '' + this.log('responseXML() %s', contentType) + + if ( + contentType.startsWith('application/xml') || + contentType.startsWith('text/xml') + ) { + this.log('response content-type is XML, parsing...') + return new DOMParser().parseFromString(this.responseText, contentType) + } + + this.log('response content type is not XML, returning null...') + return null + } + public abort() { - this.log('abort') + this.log('abort()') if (this.readyState > this.UNSENT && this.readyState < this.DONE) { - this.setReadyState(this.UNSENT) + this.reset() this.trigger('abort') } } @@ -446,12 +550,12 @@ export const createXMLHttpRequestOverride = ( } public setRequestHeader(name: string, value: string) { - this.log('set request header "%s" to "%s"', name, value) + this.log('setRequestHeader() "%s" to "%s"', name, value) this._requestHeaders.append(name, value) } public getResponseHeader(name: string): string | null { - this.log('get response header "%s"', name) + this.log('getResponseHeader() "%s"', name) if (this.readyState < this.HEADERS_RECEIVED) { this.log( @@ -474,7 +578,7 @@ export const createXMLHttpRequestOverride = ( } public getAllResponseHeaders(): string { - this.log('get all response headers') + this.log('getAllResponseHeaders()') if (this.readyState < this.HEADERS_RECEIVED) { this.log( @@ -488,70 +592,27 @@ export const createXMLHttpRequestOverride = ( } public addEventListener< - K extends keyof InternalXMLHttpRequestEventTargetEventMap - >(name: K, listener: XMLHttpRequestEventHandler) { - this.log('addEventListener', name, listener) + Event extends keyof InternalXMLHttpRequestEventTargetEventMap + >(event: Event, listener: XMLHttpRequestEventHandler) { + this.log('addEventListener', event, listener) this._events.push({ - name, + name: event, listener, }) } - public removeEventListener( - name: K, - listener: (event?: XMLHttpRequestEventMap[K]) => void + public removeEventListener( + event: Event, + listener: (event?: XMLHttpRequestEventMap[Event]) => void ): void { this.log('removeEventListener', name, listener) this._events = this._events.filter((storedEvent) => { - return storedEvent.name !== name && storedEvent.listener !== listener + return storedEvent.name !== event && storedEvent.listener !== listener }) } public overrideMimeType() {} - /** - * Resolves the response based on the `responseType` value. - */ - getResponseBody(body: string | undefined) { - // Handle an improperly set "null" value of the mocked response body. - const textBody = body ?? '' - this.log('coerced response body to', textBody) - - switch (this.responseType) { - case 'json': { - this.log('resolving response body as JSON') - return parseJson(textBody) - } - - case 'blob': { - const blobType = - this.getResponseHeader('content-type') || 'text/plain' - this.log('resolving response body as Blob', { type: blobType }) - - return new Blob([textBody], { - type: blobType, - }) - } - - case 'arraybuffer': { - this.log('resolving response body as ArrayBuffer') - const arrayBuffer = bufferFrom(textBody) - return arrayBuffer - } - - default: - return textBody - } - } - - getResponseXML() { - const contentType = this.getResponseHeader('Content-Type') - if (contentType === 'application/xml' || contentType === 'text/xml') { - return new DOMParser().parseFromString(this.responseText, contentType) - } - return null - } - /** * Propagates mock XMLHttpRequest instance callbacks * to the given XMLHttpRequest instance. @@ -608,15 +669,14 @@ export const createXMLHttpRequestOverride = ( propagateHeaders(request: XMLHttpRequest, headers: Headers) { this.log('propagating request headers to the original request', headers) - // Preserve the request headers casing. - Object.entries(headers.raw()).forEach(([name, value]) => { + for (const [headerName, headerValue] of headers) { this.log( 'setting "%s" (%s) header on the original request', - name, - value + headerName, + headerValue ) - request.setRequestHeader(name, value) - }) + request.setRequestHeader(headerName, headerValue) + } } } } diff --git a/src/interceptors/XMLHttpRequest/index.ts b/src/interceptors/XMLHttpRequest/index.ts index 91895100..dad1865c 100644 --- a/src/interceptors/XMLHttpRequest/index.ts +++ b/src/interceptors/XMLHttpRequest/index.ts @@ -1,12 +1,13 @@ import { invariant } from 'outvariant' import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary' -import { InteractiveIsomorphicRequest } from '../../InteractiveIsomorphicRequest' +import { InteractiveRequest } from '../../utils/toInteractiveRequest' import { Interceptor } from '../../Interceptor' import { AsyncEventEmitter } from '../../utils/AsyncEventEmitter' import { createXMLHttpRequestOverride } from './XMLHttpRequestOverride' export type XMLHttpRequestEventListener = ( - request: InteractiveIsomorphicRequest + request: InteractiveRequest, + requestId: string ) => Promise | void export type XMLHttpRequestEmitter = AsyncEventEmitter diff --git a/src/interceptors/XMLHttpRequest/utils/bufferFrom.test.ts b/src/interceptors/XMLHttpRequest/utils/bufferFrom.test.ts deleted file mode 100644 index 73d7bdff..00000000 --- a/src/interceptors/XMLHttpRequest/utils/bufferFrom.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @jest-environment node - */ -import { bufferFrom } from './bufferFrom' - -test('returns the same Uint8Array instance as Buffer.from', () => { - const init = 'hello world' - const buffer = bufferFrom(init) - const rawBuffer = Buffer.from(init) - expect(Buffer.compare(buffer, rawBuffer)).toBe(0) -}) diff --git a/src/interceptors/XMLHttpRequest/utils/bufferFrom.ts b/src/interceptors/XMLHttpRequest/utils/bufferFrom.ts deleted file mode 100644 index f3cc6bbe..00000000 --- a/src/interceptors/XMLHttpRequest/utils/bufferFrom.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Convert a given string into a `Uint8Array`. - * We don't use `TextEncoder` because it's unavailable in some environments. - */ -export function bufferFrom(init: string): Uint8Array { - const encodedString = encodeURIComponent(init) - const binaryString = encodedString.replace(/%([0-9A-F]{2})/g, (_, char) => { - return String.fromCharCode(('0x' + char) as any) - }) - const buffer = new Uint8Array(binaryString.length) - Array.prototype.forEach.call(binaryString, (char, index) => { - buffer[index] = char.charCodeAt(0) - }) - - return buffer -} diff --git a/src/interceptors/XMLHttpRequest/utils/concatArrayBuffer.ts b/src/interceptors/XMLHttpRequest/utils/concatArrayBuffer.ts new file mode 100644 index 00000000..e8b9fb46 --- /dev/null +++ b/src/interceptors/XMLHttpRequest/utils/concatArrayBuffer.ts @@ -0,0 +1,12 @@ +/** + * Concatenate two `Uint8Array` buffers. + */ +export function concatArrayBuffer( + left: Uint8Array, + right: Uint8Array +): Uint8Array { + const result = new Uint8Array(left.byteLength + right.byteLength) + result.set(left, 0) + result.set(right, left.byteLength) + return result +} diff --git a/src/interceptors/XMLHttpRequest/utils/concateArrayBuffer.test.ts b/src/interceptors/XMLHttpRequest/utils/concateArrayBuffer.test.ts new file mode 100644 index 00000000..fe3a63f3 --- /dev/null +++ b/src/interceptors/XMLHttpRequest/utils/concateArrayBuffer.test.ts @@ -0,0 +1,14 @@ +/** + * @jest-environment node + */ +import { concatArrayBuffer } from './concatArrayBuffer' + +const encoder = new TextEncoder() + +it('concatenates two Uint8Array buffers', () => { + const result = concatArrayBuffer( + encoder.encode('hello'), + encoder.encode('world') + ) + expect(result).toEqual(encoder.encode('helloworld')) +}) diff --git a/src/interceptors/XMLHttpRequest/utils/createResponse.ts b/src/interceptors/XMLHttpRequest/utils/createResponse.ts new file mode 100644 index 00000000..00ee66a5 --- /dev/null +++ b/src/interceptors/XMLHttpRequest/utils/createResponse.ts @@ -0,0 +1,13 @@ +import { Response } from '@remix-run/web-fetch' +import { stringToHeaders } from 'headers-polyfill' + +export function createResponse( + request: XMLHttpRequest, + responseBody: Uint8Array +): Response { + return new Response(responseBody, { + status: request.status, + statusText: request.statusText, + headers: stringToHeaders(request.getAllResponseHeaders()), + }) +} diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index 14de6d3f..0e2c45db 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -1,19 +1,9 @@ -import { - Headers, - flattenHeadersObject, - objectToHeaders, - headersToObject, -} from 'headers-polyfill' import { invariant } from 'outvariant' -import { IsomorphicRequest } from '../../IsomorphicRequest' -import { - HttpRequestEventMap, - IsomorphicResponse, - IS_PATCHED_MODULE, -} from '../../glossary' +import type { Response as ResponsePolyfill } from '@remix-run/web-fetch' +import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary' import { Interceptor } from '../../Interceptor' -import { toIsoResponse } from '../../utils/toIsoResponse' -import { InteractiveIsomorphicRequest } from '../../InteractiveIsomorphicRequest' +import { uuidv4 } from '../../utils/uuid' +import { toInteractiveRequest } from '../../utils/toInteractiveRequest' export class FetchInterceptor extends Interceptor { static symbol = Symbol('fetch') @@ -38,73 +28,51 @@ export class FetchInterceptor extends Interceptor { ) globalThis.fetch = async (input, init) => { + const requestId = uuidv4() const request = new Request(input, init) - const url = typeof input === 'string' ? input : input.url - const method = request.method + this.log('[%s] %s', request.method, request.url) - this.log('[%s] %s', method, url) - - const body = await request.clone().arrayBuffer() - const isomorphicRequest = new IsomorphicRequest( - new URL(url, location.origin), - { - body, - method, - headers: new Headers(request.headers), - credentials: request.credentials, - } - ) - - const interactiveIsomorphicRequest = new InteractiveIsomorphicRequest( - isomorphicRequest - ) - - this.log('isomorphic request', interactiveIsomorphicRequest) + const interactiveRequest = toInteractiveRequest(request) this.log( 'emitting the "request" event for %d listener(s)...', this.emitter.listenerCount('request') ) - this.emitter.emit('request', interactiveIsomorphicRequest) + this.emitter.emit('request', interactiveRequest, requestId) this.log('awaiting for the mocked response...') - await this.emitter.untilIdle('request', ({ args: [request] }) => { - return request.id === interactiveIsomorphicRequest.id - }) + await this.emitter.untilIdle( + 'request', + ({ args: [, pendingRequestId] }) => { + return pendingRequestId === requestId + } + ) this.log('all request listeners have been resolved!') - const [mockedResponse] = - await interactiveIsomorphicRequest.respondWith.invoked() + const [mockedResponse] = await interactiveRequest.respondWith.invoked() this.log('event.respondWith called with:', mockedResponse) if (mockedResponse) { this.log('received mocked response:', mockedResponse) - - const isomorphicResponse = toIsoResponse(mockedResponse) - this.log('derived isomorphic response:', isomorphicResponse) + const responseCloine = mockedResponse.clone() this.emitter.emit( 'response', - interactiveIsomorphicRequest, - isomorphicResponse + responseCloine, + interactiveRequest, + requestId ) - const response = new Response(mockedResponse.body, { - ...isomorphicResponse, - // `Response.headers` cannot be instantiated with the `Headers` polyfill. - // Apparently, it halts if the `Headers` class contains unknown properties - // (i.e. the internal `Headers.map`). - headers: flattenHeadersObject(mockedResponse.headers || {}), - }) + const response = new Response(mockedResponse.body, mockedResponse) // Set the "response.url" property to equal the intercepted request URL. Object.defineProperty(response, 'url', { writable: false, enumerable: true, configurable: false, - value: interactiveIsomorphicRequest.url.href, + value: request.url, }) return response @@ -112,15 +80,19 @@ export class FetchInterceptor extends Interceptor { this.log('no mocked response received!') - return pureFetch(request).then(async (response) => { - const cloneResponse = response.clone() - this.log('original fetch performed', cloneResponse) + console.log('or req headers', Array.from(request.headers.entries())) + + return pureFetch(request).then((response) => { + const responseClone = response.clone() as ResponsePolyfill + this.log('original fetch performed', responseClone) this.emitter.emit( 'response', - interactiveIsomorphicRequest, - await normalizeFetchResponse(cloneResponse) + responseClone, + interactiveRequest, + requestId ) + return response }) } @@ -142,14 +114,3 @@ export class FetchInterceptor extends Interceptor { }) } } - -async function normalizeFetchResponse( - response: Response -): Promise { - return { - status: response.status, - statusText: response.statusText, - headers: objectToHeaders(headersToObject(response.headers)), - body: await response.text(), - } -} diff --git a/src/utils/RequestWithCredentials.ts b/src/utils/RequestWithCredentials.ts new file mode 100644 index 00000000..2fd5c5f3 --- /dev/null +++ b/src/utils/RequestWithCredentials.ts @@ -0,0 +1,21 @@ +import { Request } from '@remix-run/web-fetch' + +/** + * Custom wrapper around Remix's "Request" until it + * supports "credentials" correctly. + * @see https://github.com/remix-run/web-std-io/pull/21 + */ +export function createRequestWithCredentials( + input: string | URL | Request, + init?: RequestInit +): Request { + const request = new Request(input, init) + + Object.defineProperty(request, 'credentials', { + enumerable: true, + writable: false, + value: init?.credentials || 'include', + }) + + return request +} diff --git a/src/utils/bufferUtils.ts b/src/utils/bufferUtils.ts index 881c289d..563b3693 100644 --- a/src/utils/bufferUtils.ts +++ b/src/utils/bufferUtils.ts @@ -1,9 +1,9 @@ import { TextDecoder, TextEncoder } from 'web-encoding' -export function encodeBuffer(text: string): ArrayBuffer { - const encoder = new TextEncoder() - const encoded = encoder.encode(text) - return getArrayBuffer(encoded) +const encoder = new TextEncoder() + +export function encodeBuffer(text: string): Uint8Array { + return encoder.encode(text) } export function decodeBuffer(buffer: ArrayBuffer, encoding?: string): string { @@ -11,7 +11,12 @@ export function decodeBuffer(buffer: ArrayBuffer, encoding?: string): string { return decoder.decode(buffer) } -export function getArrayBuffer(array: Uint8Array): ArrayBuffer { +/** + * Create an `ArrayBuffer` from the given `Uint8Array`. + * Takes the byte offset into account to produce the right buffer + * in the case when the buffer is bigger than the data view. + */ +export function toArrayBuffer(array: Uint8Array): ArrayBuffer { return array.buffer.slice( array.byteOffset, array.byteOffset + array.byteLength diff --git a/src/utils/parseJson.ts b/src/utils/parseJson.ts index 75368728..af8d2e6d 100644 --- a/src/utils/parseJson.ts +++ b/src/utils/parseJson.ts @@ -2,7 +2,7 @@ * Parses a given string into JSON. * Gracefully handles invalid JSON by returning `null`. */ -export function parseJson(data: string): Record | null { +export function parseJson(data: string): Record | null { try { const json = JSON.parse(data) return json diff --git a/src/utils/toInteractiveRequest.ts b/src/utils/toInteractiveRequest.ts new file mode 100644 index 00000000..e5acad3c --- /dev/null +++ b/src/utils/toInteractiveRequest.ts @@ -0,0 +1,29 @@ +import { format } from 'outvariant' +import { createLazyCallback, LazyCallback } from './createLazyCallback' + +type LazyResponseCallback = (response: Response) => void + +export type InteractiveRequest = globalThis.Request & { + respondWith: LazyCallback +} + +export function toInteractiveRequest(request: Request): InteractiveRequest { + Object.defineProperty(request, 'respondWith', { + writable: false, + enumerable: true, + value: createLazyCallback({ + maxCalls: 1, + maxCallsCallback() { + throw new Error( + format( + 'Failed to respond to "%s %s" request: the "request" event has already been responded to.', + request.method, + request.url + ) + ) + }, + }), + }) + + return request as InteractiveRequest +} diff --git a/src/utils/toIsoResponse.test.ts b/src/utils/toIsoResponse.test.ts deleted file mode 100644 index b990d6a5..00000000 --- a/src/utils/toIsoResponse.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Headers } from 'headers-polyfill' -import { toIsoResponse } from './toIsoResponse' - -test('returns a well-formed empty response', () => { - expect(toIsoResponse({})).toEqual({ - status: 200, - statusText: 'OK', - headers: new Headers(), - }) -}) - -test('uses fallback values for the missing response properties', () => { - expect(toIsoResponse({ status: 301, body: 'text-body' })).toEqual({ - status: 301, - statusText: 'OK', - headers: new Headers(), - body: 'text-body', - }) -}) - -test('returns a full response as-is, converting the headers', () => { - expect( - toIsoResponse({ - status: 301, - statusText: 'Custom Status', - headers: { - 'X-Allowed': 'yes', - }, - body: 'text-body', - }) - ).toEqual({ - status: 301, - statusText: 'Custom Status', - headers: new Headers({ - 'X-Allowed': 'yes', - }), - body: 'text-body', - }) -}) diff --git a/src/utils/toIsoResponse.ts b/src/utils/toIsoResponse.ts deleted file mode 100644 index a7544a62..00000000 --- a/src/utils/toIsoResponse.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { objectToHeaders } from 'headers-polyfill' -import { IsomorphicResponse, MockedResponse } from '../glossary' - -/** - * Converts a given mocked response object into an isomorphic response. - */ -export function toIsoResponse(response: MockedResponse): IsomorphicResponse { - return { - status: response.status ?? 200, - statusText: response.statusText || 'OK', - headers: objectToHeaders(response.headers || {}), - body: response.body, - } -} diff --git a/test/features/events/request.test.ts b/test/features/events/request.test.ts index 7409b0da..2092afa1 100644 --- a/test/features/events/request.test.ts +++ b/test/features/events/request.test.ts @@ -5,11 +5,10 @@ import * as http from 'http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestEventMap } from '../../../src' import { createXMLHttpRequest, waitForClientRequest } from '../../helpers' -import { anyUuid, headersContaining } from '../../jest.expect' +import { anyUuid } from '../../jest.expect' import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' import { BatchInterceptor } from '../../../src/BatchInterceptor' import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest' -import { encodeBuffer } from '../../../src/utils/bufferUtils' const httpServer = new HttpServer((app) => { app.post('/user', (req, res) => { @@ -59,21 +58,17 @@ test('ClientRequest: emits the "request" event upon the request', async () => { await waitForClientRequest(req) expect(requestListener).toHaveBeenCalledTimes(1) - expect(requestListener).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(url), - headers: headersContaining({ - 'content-type': 'application/json', - }), - credentials: expect.anything(), - _body: encodeBuffer(JSON.stringify({ userId: 'abc-123' })), - respondWith: expect.any(Function), - }) - ) + + const [request, requestId] = requestListener.mock.calls[0] + + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.headers.get('content-type')).toBe('application/json') + expect(request.credentials).toBe('same-origin') + expect(await request.json()).toEqual({ userId: 'abc-123' }) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('XMLHttpRequest: emits the "request" event upon the request', async () => { @@ -85,25 +80,21 @@ test('XMLHttpRequest: emits the "request" event upon the request', async () => { }) /** - * @note In Node.js "XMLHttpRequest" is often polyfilled by "ClientRequest". - * This results in both "XMLHttpRequest" and "ClientRequest" interceptors - * emitting the "request" event. - * @see https://github.com/mswjs/interceptors/issues/163 + * @note There are two "request" events emitted because XMLHttpRequest + * is polyfilled by "http.ClientRequest" in JSDOM. When this request gets + * bypassed by XMLHttpRequest interceptor, JSDOM constructs "http.ClientRequest" + * to perform it as-is. This issues an additional OPTIONS request first. */ expect(requestListener).toHaveBeenCalledTimes(2) - expect(requestListener).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(url), - headers: headersContaining({ - 'content-type': 'application/json', - }), - credentials: 'same-origin', - _body: encodeBuffer(JSON.stringify({ userId: 'abc-123' })), - respondWith: expect.any(Function), - }) - ) + + const [request, requestId] = requestListener.mock.calls[0] + + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.headers.get('content-type')).toBe('application/json') + expect(request.credentials).toBe('omit') + expect(await request.json()).toEqual({ userId: 'abc-123' }) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) diff --git a/test/features/events/response.test.ts b/test/features/events/response.test.ts index 17b54047..bee99bcb 100644 --- a/test/features/events/response.test.ts +++ b/test/features/events/response.test.ts @@ -4,18 +4,13 @@ import * as https from 'https' import fetch from 'node-fetch' import waitForExpect from 'wait-for-expect' +import { Response } from '@remix-run/web-fetch' import { HttpServer, httpsAgent } from '@open-draft/test-server/http' -import { - HttpRequestEventMap, - IsomorphicRequest, - IsomorphicResponse, -} from '../../../src' +import { HttpRequestEventMap } from '../../../src' import { createXMLHttpRequest, waitForClientRequest } from '../../helpers' -import { anyUuid, headersContaining } from '../../jest.expect' import { XMLHttpRequestInterceptor } from '../../../src/interceptors/XMLHttpRequest' import { BatchInterceptor } from '../../../src/BatchInterceptor' import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' -import { encodeBuffer } from '../../../src/utils/bufferUtils' declare namespace window { export const _resourceLoader: { @@ -44,15 +39,20 @@ const interceptor = new BatchInterceptor({ new XMLHttpRequestInterceptor(), ], }) + interceptor.on('request', (request) => { - if (request.url.pathname === '/user') { - request.respondWith({ - status: 200, - headers: { - 'x-response-type': 'mocked', - }, - body: 'mocked-response-text', - }) + const url = new URL(request.url) + + if (url.pathname === '/user') { + request.respondWith( + new Response('mocked-response-text', { + status: 200, + statusText: 'OK', + headers: { + 'x-response-type': 'mocked', + }, + }) + ) } }) @@ -67,7 +67,6 @@ beforeAll(async () => { window._resourceLoader._strictSSL = false await httpServer.listen() - interceptor.apply() }) @@ -80,7 +79,7 @@ afterAll(async () => { await httpServer.close() }) -test('ClientRequest: emits the "response" event upon a mocked response', async () => { +test('ClientRequest: emits the "response" event for a mocked response', async () => { const req = https.request(httpServer.https.url('/user'), { method: 'GET', headers: { @@ -88,33 +87,22 @@ test('ClientRequest: emits the "response" event upon a mocked response', async ( }, }) req.end() - const { text } = await waitForClientRequest(req) + await waitForClientRequest(req) expect(responseListener).toHaveBeenCalledTimes(1) - expect(responseListener).toHaveBeenCalledWith< - [IsomorphicRequest, IsomorphicResponse] - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(httpServer.https.url('/user')), - headers: headersContaining({ - 'x-request-custom': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - }), - { - status: 200, - statusText: 'OK', - headers: headersContaining({ - 'x-response-type': 'mocked', - }), - body: 'mocked-response-text', - } - ) - - expect(await text()).toEqual('mocked-response-text') + + const [response, request] = responseListener.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.https.url('/user')) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.headers.get('x-response-type')).toBe('mocked') + expect(await response.text()).toBe('mocked-response-text') }) test('ClientRequest: emits the "response" event upon the original response', async () => { @@ -127,33 +115,22 @@ test('ClientRequest: emits the "response" event upon the original response', asy }) req.write('request-body') req.end() - const { text } = await waitForClientRequest(req) + await waitForClientRequest(req) expect(responseListener).toHaveBeenCalledTimes(1) - expect(responseListener).toHaveBeenCalledWith< - [IsomorphicRequest, IsomorphicResponse] - >( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(httpServer.https.url('/account')), - headers: headersContaining({ - 'x-request-custom': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer('request-body'), - }), - { - status: 200, - statusText: 'OK', - headers: headersContaining({ - 'x-response-type': 'original', - }), - body: 'original-response-text', - } - ) - - expect(await text()).toEqual('original-response-text') + + const [response, request] = responseListener.mock.calls[0] + + expect(request.method).toBe('POST') + expect(request.url).toBe(httpServer.https.url('/account')) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('request-body') + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.headers.get('x-response-type')).toBe('original') + expect(await response.text()).toBe('original-response-text') }) test('XMLHttpRequest: emits the "response" event upon a mocked response', async () => { @@ -164,28 +141,21 @@ test('XMLHttpRequest: emits the "response" event upon a mocked response', async }) expect(responseListener).toHaveBeenCalledTimes(1) - expect(responseListener).toHaveBeenCalledWith< - [IsomorphicRequest, IsomorphicResponse] - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(httpServer.https.url('/user')), - headers: headersContaining({ - 'x-request-custom': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer(''), - }), - { - status: 200, - statusText: 'OK', - headers: headersContaining({ - 'x-response-type': 'mocked', - }), - body: 'mocked-response-text', - } - ) + + const [response, request] = responseListener.mock.calls.find((call) => { + return call[1].method === 'GET' + })! + + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.https.url('/user')) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('omit') + expect(request.body).toBe(null) + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.headers.get('x-response-type')).toBe('mocked') + expect(await response.text()).toBe('mocked-response-text') // Original response. expect(originalRequest.responseText).toEqual('mocked-response-text') @@ -199,34 +169,31 @@ test('XMLHttpRequest: emits the "response" event upon the original response', as }) /** - * @note In Node.js "XMLHttpRequest" is often polyfilled by "ClientRequest". - * This results in both "XMLHttpRequest" and "ClientRequest" interceptors - * emitting the "request" event. - * @see https://github.com/mswjs/interceptors/issues/163 + * @note The "response" event will be emitted twice because XMLHttpRequest + * is polyfilled by "http.ClientRequest" in Node.js. When this request will be + * passthrough to the ClientRequest, it will perform an "OPTIONS" request first, + * thus two request/response events emitted. */ expect(responseListener).toHaveBeenCalledTimes(2) - expect(responseListener).toHaveBeenCalledWith< - [IsomorphicRequest, IsomorphicResponse] - >( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(httpServer.https.url('/account')), - headers: headersContaining({ - 'x-request-custom': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer('request-body'), - }), - { - status: 200, - statusText: 'OK', - headers: headersContaining({ - 'x-response-type': 'original', - }), - body: 'original-response-text', - } - ) + + // Lookup the correct response listener call. + const [response, request] = responseListener.mock.calls.find((call) => { + return call[1].method === 'POST' + })! + + expect(request).toBeDefined() + expect(response).toBeDefined() + + expect(request.method).toBe('POST') + expect(request.url).toBe(httpServer.https.url('/account')) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('omit') + expect(await request.text()).toBe('request-body') + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.headers.get('x-response-type')).toBe('original') + expect(await response.text()).toBe('original-response-text') // Original response. expect(originalRequest.responseText).toEqual('original-response-text') @@ -240,28 +207,19 @@ test('fetch: emits the "response" event upon a mocked response', async () => { }) expect(responseListener).toHaveBeenCalledTimes(1) - expect(responseListener).toHaveBeenCalledWith< - [IsomorphicRequest, IsomorphicResponse] - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(httpServer.https.url('/user')), - headers: headersContaining({ - 'x-request-custom': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - }), - { - status: 200, - statusText: 'OK', - headers: headersContaining({ - 'x-response-type': 'mocked', - }), - body: 'mocked-response-text', - } - ) + + const [response, request] = responseListener.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.https.url('/user')) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.headers.get('x-response-type')).toBe('mocked') + expect(await response.text()).toBe('mocked-response-text') }) test('fetch: emits the "response" event upon the original response', async () => { @@ -277,26 +235,17 @@ test('fetch: emits the "response" event upon the original response', async () => await waitForExpect(() => { expect(responseListener).toHaveBeenCalledTimes(1) }) - expect(responseListener).toHaveBeenCalledWith< - [IsomorphicRequest, IsomorphicResponse] - >( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(httpServer.https.url('/account')), - headers: headersContaining({ - 'x-request-custom': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer('request-body'), - }), - { - status: 200, - statusText: 'OK', - headers: headersContaining({ - 'x-response-type': 'original', - }), - body: 'original-response-text', - } - ) + + const [response, request] = responseListener.mock.calls[0] + + expect(request.method).toBe('POST') + expect(request.url).toBe(httpServer.https.url('/account')) + expect(request.headers.get('x-request-custom')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('request-body') + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.headers.get('x-response-type')).toBe('original') + expect(await response.text()).toBe('original-response-text') }) diff --git a/test/features/remote/remote.test.ts b/test/features/remote/remote.test.ts index 3462e4ee..3a4b9cea 100644 --- a/test/features/remote/remote.test.ts +++ b/test/features/remote/remote.test.ts @@ -1,5 +1,9 @@ +/** + * @jest-environment node + */ import * as path from 'path' import { spawn } from 'child_process' +import { Response } from '@remix-run/web-fetch' import { RemoteHttpResolver } from '../../../src/RemoteHttpInterceptor' const CHILD_PATH = path.resolve(__dirname, 'child.js') @@ -13,15 +17,19 @@ const resolver = new RemoteHttpResolver({ }) resolver.on('request', (request) => { - request.respondWith({ - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - mockedFromParent: true, - }), - }) + request.respondWith( + new Response( + JSON.stringify({ + mockedFromParent: true, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + } + ) + ) }) beforeAll(() => { diff --git a/test/helpers.ts b/test/helpers.ts index 29fea09d..5232ae95 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,12 +1,13 @@ import https from 'https' import http, { ClientRequest, IncomingMessage, RequestOptions } from 'http' import nodeFetch, { Response, RequestInfo, RequestInit } from 'node-fetch' +import { objectToHeaders } from 'headers-polyfill' +import type { Request } from '@remix-run/web-fetch' import { Page, ScenarioApi } from 'page-with' import { getRequestOptionsByUrl } from '../src/utils/getRequestOptionsByUrl' import { getIncomingMessageBody } from '../src/interceptors/ClientRequest/utils/getIncomingMessageBody' -import { RequestCredentials } from '../src/glossary' -import { IsomorphicRequest } from '../src' -import { encodeBuffer } from '../src/utils/bufferUtils' +import { SerializedRequest } from '../src/RemoteHttpInterceptor' +import { createRequestWithCredentials } from '../src/utils/RequestWithCredentials' export interface PromisifiedResponse { req: ClientRequest @@ -173,7 +174,6 @@ export function createXMLHttpRequest( resolve(req) }) - req.addEventListener('error', reject) req.addEventListener('abort', reject) }) } @@ -185,15 +185,6 @@ export interface XMLHttpResponse { body: string } -export interface StringifiedIsomorphicRequest { - id: string - method: string - url: string - headers: Record - credentials: RequestCredentials - body?: string -} - interface BrowserXMLHttpRequestInit { method: string url: string @@ -202,11 +193,9 @@ interface BrowserXMLHttpRequestInit { withCredentials?: boolean } -export async function extractRequestFromPage( - page: Page -): Promise { - const request = await page.evaluate(() => { - return new Promise((resolve, reject) => { +export async function extractRequestFromPage(page: Page): Promise { + const requestJson = await page.evaluate(() => { + return new Promise((resolve, reject) => { const timeoutTimer = setTimeout(() => { reject( new Error( @@ -217,7 +206,7 @@ export async function extractRequestFromPage( window.addEventListener( 'resolver' as any, - (event: CustomEvent) => { + (event: CustomEvent) => { clearTimeout(timeoutTimer) resolve(event.detail) } @@ -225,12 +214,16 @@ export async function extractRequestFromPage( }) }) - const isomorphicRequest = new IsomorphicRequest(new URL(request.url), { - ...request, - body: encodeBuffer(request.body || ''), + const request = createRequestWithCredentials(requestJson.url, { + method: requestJson.method, + headers: objectToHeaders(requestJson.headers), + credentials: requestJson.credentials, + body: ['GET', 'HEAD'].includes(requestJson.method) + ? null + : requestJson.body, }) - isomorphicRequest.id = request.id - return isomorphicRequest + + return request } export function createRawBrowserXMLHttpRequest(scenario: ScenarioApi) { @@ -270,8 +263,8 @@ export function createRawBrowserXMLHttpRequest(scenario: ScenarioApi) { resolve({ status: this.status, statusText: this.statusText, - body: this.response, headers: this.getAllResponseHeaders(), + body: this.response, }) }) request.addEventListener('error', reject) @@ -286,7 +279,7 @@ export function createRawBrowserXMLHttpRequest(scenario: ScenarioApi) { export function createBrowserXMLHttpRequest(scenario: ScenarioApi) { return async ( requestInit: BrowserXMLHttpRequestInit - ): Promise<[IsomorphicRequest, XMLHttpResponse]> => { + ): Promise<[Request, XMLHttpResponse]> => { return Promise.all([ extractRequestFromPage(scenario.page), createRawBrowserXMLHttpRequest(scenario)(requestInit), diff --git a/test/jest.node.config.js b/test/jest.node.config.js index e63d9da4..80f217e9 100644 --- a/test/jest.node.config.js +++ b/test/jest.node.config.js @@ -1,5 +1,6 @@ module.exports = { testRegex: '(? { - if (request.url.href === 'https://test.mswjs.io/user') { - request.respondWith({ - status: 200, - statusText: 'OK', - headers: { - 'content-type': 'application/json', - 'x-header': 'yes', - }, - body: JSON.stringify({ - mocked: true, - }), - }) + if (request.url === 'https://test.mswjs.io/user') { + request.respondWith( + new Response(JSON.stringify({ mocked: true }), { + status: 200, + statusText: 'OK', + headers: { + 'content-type': 'application/json', + 'x-header': 'yes', + }, + }) + ) } }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts index 55bf0f82..bbea2291 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-events-order.test.ts @@ -1,7 +1,9 @@ /** * @jest-environment jsdom + * @see https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest#instance_methods */ import { HttpServer } from '@open-draft/test-server/http' +import { Response } from '@remix-run/web-fetch' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' @@ -16,19 +18,16 @@ const httpServer = new HttpServer((app) => { const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', (request) => { - switch (request.url.pathname) { + const url = new URL(request.url) + + switch (url.pathname) { case '/user': { - request.respondWith({ - status: 200, - }) + request.respondWith(new Response()) break } case '/numbers-mock': { - request.respondWith({ - status: 200, - body: JSON.stringify([1, 2, 3]), - }) + request.respondWith(new Response(JSON.stringify([1, 2, 3]))) break } } @@ -65,22 +64,23 @@ test('emits correct events sequence for an unhandled request with no response bo interceptor.apply() const listener = jest.fn() const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url()) spyOnEvents(req, listener) + req.open('GET', httpServer.http.url()) req.send() }) expect(listener.mock.calls).toEqual([ + ['readystatechange', 1], // OPEN ['loadstart', 1], - ['readystatechange', 2], - ['readystatechange', 4], + ['readystatechange', 2], // HEADERS_RECEIVED + ['readystatechange', 4], // DONE + ['load', 4], /** * @note XMLHttpRequest polyfill from JSDOM dispatches the "readystatechange" listener. * XMLHttpRequest override also dispatches the "readystatechange" listener for the original - * request explicitly to it never hangs. This results in the listener being called twice. + * request explicitly so it never hangs. This results in the listener being called twice. */ ['readystatechange', 4], - ['load', 4], ['loadend', 4], ]) expect(req.readyState).toEqual(4) @@ -90,15 +90,17 @@ test('emits correct events sequence for a handled request with no response body' interceptor.apply() const listener = jest.fn() const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/user')) spyOnEvents(req, listener) + req.open('GET', httpServer.http.url('/user')) req.send() }) expect(listener.mock.calls).toEqual([ + ['readystatechange', 1], // OPEN ['loadstart', 1], - ['readystatechange', 2], - ['readystatechange', 4], + ['readystatechange', 2], // HEADERS_RECEIVED + ['readystatechange', 3], // LOADING + ['readystatechange', 4], // DONE ['load', 4], ['loadend', 4], ]) @@ -109,22 +111,23 @@ test('emits correct events sequence for an unhandled request with a response bod interceptor.apply() const listener = jest.fn() const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/numbers')) spyOnEvents(req, listener) + req.open('GET', httpServer.http.url('/numbers')) req.send() }) expect(listener.mock.calls).toEqual([ + ['readystatechange', 1], // OPEN ['loadstart', 1], - ['readystatechange', 2], - ['readystatechange', 3], + ['readystatechange', 2], // HEADERS_RECEIVED + ['readystatechange', 3], // LOADING ['progress', 3], ['readystatechange', 4], + ['load', 4], /** * @note The same issue with the "readystatechange" callback being called twice. */ ['readystatechange', 4], - ['load', 4], ['loadend', 4], ]) expect(req.readyState).toBe(4) @@ -134,17 +137,18 @@ test('emits correct events sequence for a handled request with a response body', interceptor.apply() const listener = jest.fn() const req = await createXMLHttpRequest((req) => { - req.open('GET', httpServer.http.url('/numbers-mock')) spyOnEvents(req, listener) + req.open('GET', httpServer.http.url('/numbers-mock')) req.send() }) expect(listener.mock.calls).toEqual([ + ['readystatechange', 1], // OPEN ['loadstart', 1], - ['readystatechange', 2], - ['readystatechange', 3], + ['readystatechange', 2], // HEADERS_RECEIVED + ['readystatechange', 3], // LOADING ['progress', 3], - ['readystatechange', 4], + ['readystatechange', 4], // DONE ['load', 4], ['loadend', 4], ]) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts index 82e182c5..05c7a23c 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts @@ -7,7 +7,7 @@ import axios from 'axios' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', (request) => { +interceptor.on('request', () => { throw new Error('Custom error message') }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts new file mode 100644 index 00000000..11cfc8c5 --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-modify-request.test.ts @@ -0,0 +1,44 @@ +/** + * @jest-environment jsdom + */ +import { HttpServer } from '@open-draft/test-server/http' +import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '../../../helpers' + +const server = new HttpServer((app) => { + app.get('/user', (req, res) => { + res + .set({ + // Explicitly allow for this custom header to be + // exposed on the response. Otherwise it's ignored. + 'Access-Control-Expose-Headers': 'x-appended-header', + 'X-Appended-Header': req.headers['x-appended-header'], + }) + .end() + }) +}) + +const interceptor = new XMLHttpRequestInterceptor() + +beforeAll(async () => { + await server.listen() + interceptor.apply() +}) + +afterAll(async () => { + await server.close() + interceptor.dispose() +}) + +it('allows modifying outgoing request headers', async () => { + interceptor.on('request', (request) => { + request.headers.set('X-Appended-Header', 'modified') + }) + + const req = await createXMLHttpRequest((req) => { + req.open('GET', server.http.url('/user')) + req.send() + }) + + expect(req.getResponseHeader('x-appended-header')).toBe('modified') +}) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts index ef8447a2..bfb42028 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-request-headers.test.ts @@ -6,7 +6,7 @@ import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpR import { createXMLHttpRequest } from '../../../helpers' interface ResponseType { - requestRawHeaders: string[] + requestRawHeaders: Array } const httpServer = new HttpServer((app) => { @@ -19,9 +19,7 @@ const httpServer = new HttpServer((app) => { 'X-Client-Header': req.get('x-client-header'), 'X-Multi-Value': req.get('x-multi-value'), }) - .json({ - requestRawHeaders: req.rawHeaders, - }) + .end() }) }) @@ -45,11 +43,6 @@ test('sends the request headers to the server', async () => { req.setRequestHeader('X-Multi-Value', 'value1; value2') req.send() }) - const res = JSON.parse(req.responseText) as ResponseType - - // Request headers casing is preserved in the raw headers. - expect(res.requestRawHeaders).toContain('X-ClienT-HeadeR') - expect(res.requestRawHeaders).toContain('X-Multi-Value') // Normalized request headers list all headers in lower-case. expect(req.getResponseHeader('x-client-header')).toEqual('abc-123') diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts index d111be4a..fb341be3 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-empty.test.ts @@ -1,19 +1,18 @@ /** * @jest-environment jsdom */ +import { Response } from '@remix-run/web-fetch' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', (request) => { - request.respondWith({ - status: 401, - statusText: 'Unathorized', - // @ts-nocheck JavaScript clients and type-casting may - // circument the mocked response body type signature, - // setting in invalid value. - body: null as any, - }) + request.respondWith( + new Response(null, { + status: 401, + statusText: 'Unauthorized', + }) + ) }) beforeAll(() => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts index a3674e37..85f2c9eb 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-json-invalid.test.ts @@ -1,26 +1,28 @@ /** * @jest-environment jsdom */ +import { Response } from '@remix-run/web-fetch' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', (request) => { - switch (request.url.pathname) { + const url = new URL(request.url) + + switch (url.pathname) { case '/no-body': { - request.respondWith({ - status: 204, - }) + request.respondWith(new Response(null, { status: 204 })) break } case '/invalid-json': { - request.respondWith({ - headers: { - 'Content-Type': 'application/json', - }, - body: `{"invalid: js'on`, - }) + request.respondWith( + new Response(`{"invalid: js'on`, { + headers: { + 'Content-Type': 'application/json', + }, + }) + ) break } } diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts index 6f10d347..5ab5600e 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-body-xml.test.ts @@ -1,6 +1,7 @@ /** * @jest-environment jsdom */ +import { Response } from '@remix-run/web-fetch' import { DOMParser } from '@xmldom/xmldom' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' @@ -10,11 +11,12 @@ const XML_STRING = 'Content' describe('Content-Type: application/xml', () => { const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', (request) => { - request.respondWith({ - headers: { 'Content-Type': 'application/xml' }, - status: 200, - body: XML_STRING, - }) + request.respondWith( + new Response(XML_STRING, { + status: 200, + headers: { 'Content-Type': 'application/xml' }, + }) + ) }) beforeAll(() => { @@ -40,11 +42,12 @@ describe('Content-Type: application/xml', () => { describe('Content-Type: text/xml', () => { const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', (request) => { - request.respondWith({ - headers: { 'Content-Type': 'text/xml' }, - status: 200, - body: XML_STRING, - }) + request.respondWith( + new Response(XML_STRING, { + status: 200, + headers: { 'Content-Type': 'text/xml' }, + }) + ) }) beforeAll(() => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts index 51973388..41a02cfb 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-headers.test.ts @@ -2,6 +2,7 @@ * @jest-environment jsdom */ import { HttpServer } from '@open-draft/test-server/http' +import { Response } from '@remix-run/web-fetch' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' @@ -20,16 +21,20 @@ const httpServer = new HttpServer((app) => { const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', (request) => { - if (!request.url.searchParams.has('mock')) { + const url = new URL(request.url) + + if (!url.searchParams.has('mock')) { return } - request.respondWith({ - headers: { - etag: '123', - 'x-response-type': 'mock', - }, - }) + request.respondWith( + new Response(null, { + headers: { + etag: '123', + 'x-response-type': 'mock', + }, + }) + ) }) beforeAll(async () => { diff --git a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts index 253c38c7..ff5b384c 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-response-type.test.ts @@ -1,20 +1,27 @@ /** * @jest-environment jsdom */ +import { Response } from '@remix-run/web-fetch' +import { encodeBuffer } from '../../../../src' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { toArrayBuffer } from '../../../../src/utils/bufferUtils' import { createXMLHttpRequest, readBlob } from '../../../helpers' const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', (request) => { - request.respondWith({ - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Maverick', - }), - }) + request.respondWith( + new Response( + JSON.stringify({ + firstName: 'John', + lastName: 'Maverick', + }), + { + headers: { + 'Content-Type': 'application/json', + }, + } + ) + ) }) beforeAll(() => { @@ -80,8 +87,8 @@ test('responds with an ArrayBuffer when "responseType" equals "arraybuffer"', as req.send() }) - const expectedArrayBuffer = new Uint8Array( - Buffer.from( + const expectedArrayBuffer = toArrayBuffer( + encodeBuffer( JSON.stringify({ firstName: 'John', lastName: 'Maverick', @@ -89,7 +96,16 @@ test('responds with an ArrayBuffer when "responseType" equals "arraybuffer"', as ) ) - const responseBuffer: Uint8Array = req.response + const responseBuffer = req.response as ArrayBuffer + + const isBufferEqual = (left: ArrayBuffer, right: ArrayBuffer): boolean => { + const first = new Uint8Array(left) + const last = new Uint8Array(right) + return first.every((value, index) => last[index] === value) + } - expect(Buffer.compare(responseBuffer, expectedArrayBuffer)).toBe(0) + // Must return an "ArrayBuffer" instance for "arraybuffer" response type. + expect(responseBuffer).toBeInstanceOf(ArrayBuffer) + expect(responseBuffer.byteLength).toBe(expectedArrayBuffer.byteLength) + expect(isBufferEqual(responseBuffer, expectedArrayBuffer)).toBe(true) }) diff --git a/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts new file mode 100644 index 00000000..c131a102 --- /dev/null +++ b/test/modules/XMLHttpRequest/compliance/xhr-status.test.ts @@ -0,0 +1,59 @@ +/** + * @jest-environment jsdom + * @see https://github.com/mswjs/interceptors/issues/281 + */ +import { Response } from '@remix-run/web-fetch' +import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { createXMLHttpRequest } from '../../../helpers' + +const interceptor = new XMLHttpRequestInterceptor() + +interceptor.on('request', (request) => { + const url = new URL(request.url) + + if (url.pathname === '/cors') { + throw new Error('CORS emulation') + } + + const status = url.searchParams.get('status') + if (!status) { + return + } + + request.respondWith(new Response(null, { status: Number(status) })) +}) + +beforeAll(() => { + interceptor.apply() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('keeps "status" as 0 if the request fails', async () => { + const request = await createXMLHttpRequest((request) => { + request.open('GET', '/cors') + request.send() + }) + + expect(request.status).toBe(0) +}) + +it('respects error response status', async () => { + const request = await createXMLHttpRequest((request) => { + request.open('GET', '?status=500') + request.send() + }) + + expect(request.status).toBe(500) +}) + +it('respects a custom "status" from the response', async () => { + const request = await createXMLHttpRequest((request) => { + request.open('GET', '/?status=201') + request.send() + }) + + expect(request.status).toBe(201) +}) diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.runtime.js b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.runtime.js index 6f8e0656..d6870fb9 100644 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.runtime.js +++ b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.runtime.js @@ -1,14 +1,14 @@ import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/lib/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', async (request) => { +interceptor.on('request', async (request, requestId) => { window.dispatchEvent( new CustomEvent('resolver', { detail: { - id: request.id, + id: requestId, method: request.method, - url: request.url.href, - headers: request.headers.all(), + url: request.url, + headers: Object.fromEntries(request.headers.entries()), credentials: request.credentials, body: await request.text(), }, diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts index 973926df..0489c8dc 100644 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts +++ b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.browser.test.ts @@ -5,10 +5,8 @@ import * as path from 'path' import { pageWith } from 'page-with' import { HttpServer } from '@open-draft/test-server/http' import { RequestHandler } from 'express-serve-static-core' -import { createBrowserXMLHttpRequest } from '../../../helpers' -import { IsomorphicRequest, IsomorphicResponse } from '../../../../src' -import { anyUuid, headersContaining } from '../../../jest.expect' -import { encodeBuffer } from '../../../../src/utils/bufferUtils' +import { createBrowserXMLHttpRequest, XMLHttpResponse } from '../../../helpers' +import { headersContaining } from '../../../jest.expect' const httpServer = new HttpServer((app) => { const requestHandler: RequestHandler = (_req, res) => { @@ -45,17 +43,13 @@ test('intercepts an HTTP GET request', async () => { }, }) - expect(request).toMatchObject({ - id: anyUuid(), - method: 'GET', - url: new URL(url), - headers: headersContaining({ - 'x-request-header': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer(''), - }) - expect(response).toEqual({ + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(request.headers.get('x-request-header')).toBe('yes') + expect(request.credentials).toBe('omit') + expect(request.body).toBe(null) + + expect(response).toEqual({ status: 200, statusText: 'OK', headers: headersContaining({}), @@ -77,15 +71,12 @@ test('intercepts an HTTP POST request', async () => { body: JSON.stringify({ user: 'john' }), }) - expect(request).toMatchObject({ - id: anyUuid(), - method: 'POST', - url: new URL(url), - headers: headersContaining({}), - credentials: 'omit', - _body: encodeBuffer(JSON.stringify({ user: 'john' })), - }) - expect(response).toEqual({ + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.credentials).toBe('omit') + expect(await request.json()).toEqual({ user: 'john' }) + + expect(response).toEqual({ status: 200, statusText: 'OK', headers: headersContaining({}), @@ -103,9 +94,7 @@ test('sets "credentials" to "include" on isomorphic request when "withCredential withCredentials: true, }) - expect(request).toMatchObject>({ - credentials: 'include', - }) + expect(request.credentials).toBe('include') }) test('sets "credentials" to "omit" on isomorphic request when "withCredentials" is false', async () => { @@ -118,9 +107,7 @@ test('sets "credentials" to "omit" on isomorphic request when "withCredentials" withCredentials: false, }) - expect(request).toMatchObject>({ - credentials: 'omit', - }) + expect(request.credentials).toBe('omit') }) test('sets "credentials" to "omit" on isomorphic request when "withCredentials" is not set', async () => { @@ -132,7 +119,5 @@ test('sets "credentials" to "omit" on isomorphic request when "withCredentials" url, }) - expect(request).toMatchObject>({ - credentials: 'omit', - }) + expect(request.credentials).toBe('omit') }) diff --git a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts index 7f0bd69d..6749d1ab 100644 --- a/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts +++ b/test/modules/XMLHttpRequest/intercept/XMLHttpRequest.test.ts @@ -3,14 +3,12 @@ */ import type { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' -import { IsomorphicRequest } from '../../../../src' import { XMLHttpRequestEventListener, XMLHttpRequestInterceptor, } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' import { anyUuid, headersContaining } from '../../../jest.expect' -import { encodeBuffer } from '../../../../src/utils/bufferUtils' declare namespace window { export const _resourceLoader: { @@ -63,21 +61,21 @@ test('intercepts an HTTP HEAD request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'HEAD', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('HEAD') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('omit') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTP GET request', async () => { @@ -89,21 +87,21 @@ test('intercepts an HTTP GET request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('omit') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTP POST request', async () => { @@ -115,21 +113,21 @@ test('intercepts an HTTP POST request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer('post-payload'), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('omit') + expect(await request.text()).toBe('post-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTP PUT request', async () => { @@ -141,21 +139,21 @@ test('intercepts an HTTP PUT request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'PUT', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer('put-payload'), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('PUT') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('omit') + expect(await request.text()).toBe('put-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTP DELETE request', async () => { @@ -167,21 +165,21 @@ test('intercepts an HTTP DELETE request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'DELETE', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('DELETE') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('omit') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTPS HEAD request', async () => { @@ -193,21 +191,21 @@ test('intercepts an HTTPS HEAD request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'HEAD', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('HEAD') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('omit') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTPS GET request', async () => { @@ -219,21 +217,21 @@ test('intercepts an HTTPS GET request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('omit') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTPS POST request', async () => { @@ -245,21 +243,21 @@ test('intercepts an HTTPS POST request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer('post-payload'), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('omit') + expect(await request.text()).toBe('post-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTPS PUT request', async () => { @@ -271,21 +269,21 @@ test('intercepts an HTTPS PUT request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'PUT', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer('put-payload'), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('PUT') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('omit') + expect(await request.text()).toBe('put-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTPS DELETE request', async () => { @@ -297,21 +295,21 @@ test('intercepts an HTTPS DELETE request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'DELETE', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'omit', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('DELETE') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('omit') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('sets "credentials" to "include" on isomorphic request when "withCredentials" is true', async () => { @@ -322,11 +320,9 @@ test('sets "credentials" to "include" on isomorphic request when "withCredential }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining>({ - credentials: 'include', - }) - ) + + const [request] = resolver.mock.calls[0] + expect(request.credentials).toBe('include') }) test('sets "credentials" to "omit" on isomorphic request when "withCredentials" is not set', async () => { @@ -336,11 +332,8 @@ test('sets "credentials" to "omit" on isomorphic request when "withCredentials" }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining>({ - credentials: 'omit', - }) - ) + const [request] = resolver.mock.calls[0] + expect(request.credentials).toBe('omit') }) test('sets "credentials" to "omit" on isomorphic request when "withCredentials" is false', async () => { @@ -351,9 +344,6 @@ test('sets "credentials" to "omit" on isomorphic request when "withCredentials" }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining>({ - credentials: 'omit', - }) - ) + const [request] = resolver.mock.calls[0] + expect(request.credentials).toBe('omit') }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js index a722755c..72012785 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js @@ -2,21 +2,23 @@ import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/lib/interceptors/ const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', async (request) => { +interceptor.on('request', async (request, requestId) => { window.dispatchEvent( new CustomEvent('resolver', { detail: { - id: request.id, + id: requestId, method: request.method, - url: request.url.href, - headers: request.headers.all(), + url: request.url, + headers: Object.fromEntries(request.headers.entries()), credentials: request.credentials, - body: await request.text(), + body: await request.clone().text(), }, }) ) - if (request.url.pathname === '/mocked') { + const url = new URL(request.url) + + if (url.pathname === '/mocked') { await new Promise((resolve) => setTimeout(resolve, 0)) const req = new XMLHttpRequest() @@ -27,14 +29,15 @@ interceptor.on('request', async (request) => { req.addEventListener('error', reject) }) - request.respondWith({ - status: req.status, - statusText: req.statusText, - headers: { - 'X-Custom-Header': req.getResponseHeader('X-Custom-Header'), - }, - body: `${req.responseText} world`, - }) + request.respondWith( + new Response(`${req.responseText} world`, { + status: req.status, + statusText: req.statusText, + headers: { + 'X-Custom-Header': req.getResponseHeader('X-Custom-Header'), + }, + }) + ) } }) diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts index 2df1b9ff..1b4ac800 100644 --- a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts @@ -53,6 +53,8 @@ test('responds to an HTTP request handled in the resolver', async () => { expect(response.status).toBe(200) expect(response.statusText).toBe('OK') - expect(response.headers).toBe('x-custom-header: yes') + expect(response.headers).toBe( + 'content-type: text/plain;charset=UTF-8\r\nx-custom-header: yes' + ) expect(response.body).toBe('hello world') }) diff --git a/test/modules/XMLHttpRequest/response/xhr.browser.runtime.js b/test/modules/XMLHttpRequest/response/xhr.browser.runtime.js index 1e6076e1..95efb39e 100644 --- a/test/modules/XMLHttpRequest/response/xhr.browser.runtime.js +++ b/test/modules/XMLHttpRequest/response/xhr.browser.runtime.js @@ -1,31 +1,33 @@ import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/lib/interceptors/XMLHttpRequest' const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', async (request) => { + +interceptor.on('request', async (request, requestId) => { window.dispatchEvent( new CustomEvent('resolver', { detail: { - id: request.id, + id: requestId, method: request.method, - url: request.url.href, - headers: request.headers.all(), + url: request.url, + headers: Object.fromEntries(request.headers.entries()), credentials: request.credentials, - body: await request.text(), + body: await request.clone().text(), }, }) ) const { serverHttpUrl, serverHttpsUrl } = window - if ([serverHttpUrl, serverHttpsUrl].includes(request.url.href)) { - request.respondWith({ - status: 201, - statusText: 'Created', - headers: { - 'Content-Type': 'application/hal+json', - }, - body: JSON.stringify({ mocked: true }), - }) + if ([serverHttpUrl, serverHttpsUrl].includes(request.url)) { + request.respondWith( + new Response(JSON.stringify({ mocked: true }), { + status: 201, + statusText: 'Created', + headers: { + 'Content-Type': 'application/hal+json', + }, + }) + ) } }) diff --git a/test/modules/XMLHttpRequest/response/xhr.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr.browser.test.ts index b968655c..2eaa55e0 100644 --- a/test/modules/XMLHttpRequest/response/xhr.browser.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr.browser.test.ts @@ -57,9 +57,9 @@ test('responds to an HTTP request handled in the resolver', async () => { url: httpServer.http.url('/'), }) - expect(response.status).toEqual(201) - expect(response.statusText).toEqual('Created') - expect(response.headers).toEqual('content-type: application/hal+json') + expect(response.status).toBe(201) + expect(response.statusText).toBe('Created') + expect(response.headers).toBe('content-type: application/hal+json') expect(response.body).toEqual(JSON.stringify({ mocked: true })) }) @@ -71,9 +71,9 @@ test('responds to an HTTPS request handled in the resolver', async () => { url: httpServer.https.url('/'), }) - expect(response.status).toEqual(201) - expect(response.statusText).toEqual('Created') - expect(response.headers).toEqual('content-type: application/hal+json') + expect(response.status).toBe(201) + expect(response.statusText).toBe('Created') + expect(response.headers).toBe('content-type: application/hal+json') expect(response.body).toEqual(JSON.stringify({ mocked: true })) }) @@ -85,8 +85,8 @@ test('bypasses a request not handled in the resolver', async () => { url: httpServer.http.url('/get'), }) - expect(response.status).toEqual(200) - expect(response.statusText).toEqual('OK') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') expect(response.body).toEqual(JSON.stringify({ route: '/get' })) }) @@ -105,15 +105,15 @@ test('bypasses any request when the interceptor is restored', async () => { url: httpServer.http.url('/'), }) - expect(firstResponse.status).toEqual(200) - expect(firstResponse.statusText).toEqual('OK') + expect(firstResponse.status).toBe(200) + expect(firstResponse.statusText).toBe('OK') expect(firstResponse.body).toEqual(JSON.stringify({ route: '/' })) const secondResponse = await callXMLHttpRequest({ method: 'GET', url: httpServer.http.url('/get'), }) - expect(secondResponse.status).toEqual(200) - expect(secondResponse.statusText).toEqual('OK') + expect(secondResponse.status).toBe(200) + expect(secondResponse.statusText).toBe('OK') expect(secondResponse.body).toEqual(JSON.stringify({ route: '/get' })) }) diff --git a/test/modules/XMLHttpRequest/response/xhr.test.ts b/test/modules/XMLHttpRequest/response/xhr.test.ts index 51cbc3a3..5aa2c60f 100644 --- a/test/modules/XMLHttpRequest/response/xhr.test.ts +++ b/test/modules/XMLHttpRequest/response/xhr.test.ts @@ -2,6 +2,7 @@ * @jest-environment jsdom */ import { HttpServer } from '@open-draft/test-server/http' +import { Response } from '@remix-run/web-fetch' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' @@ -30,24 +31,25 @@ const httpServer = new HttpServer((app) => { const interceptor = new XMLHttpRequestInterceptor() interceptor.on('request', (request) => { + const url = new URL(request.url) + const shouldMock = - [httpServer.http.url(), httpServer.https.url()].includes( - request.url.href - ) || ['/login'].includes(request.url.pathname) + [httpServer.http.url(), httpServer.https.url()].includes(request.url) || + ['/login'].includes(url.pathname) if (shouldMock) { - request.respondWith({ - status: 301, - statusText: 'Moved Permantently', - headers: { - 'Content-Type': 'application/hal+json', - }, - body: 'foo', - }) - return + request.respondWith( + new Response('foo', { + status: 301, + statusText: 'Moved Permantently', + headers: { + 'Content-Type': 'application/hal+json', + }, + }) + ) } - if (request.url.href === 'https://error.me/') { + if (request.url === 'https://error.me/') { throw new Error('Custom exception message') } }) @@ -57,13 +59,13 @@ beforeAll(async () => { window._resourceLoader._strictSSL = false await httpServer.listen() - interceptor.apply() }) afterAll(async () => { interceptor.dispose() await httpServer.close() + jest.restoreAllMocks() }) test('responds to an HTTP request handled in the middleware', async () => { @@ -126,15 +128,21 @@ test('responds to an HTTP request to a relative URL that is handled in the middl }) test('produces a request error when the middleware throws an exception', async () => { - const getResponse = () => { - return createXMLHttpRequest((req) => { - req.open('GET', 'https://error.me') - req.send() - }) - } + const errorListener = jest.fn() + const req = await createXMLHttpRequest((req) => { + req.open('GET', 'https://error.me') + req.addEventListener('error', errorListener) + req.send() + }) + + expect(errorListener).toHaveBeenCalledTimes(1) + + // XMLHttpRequest request exception propagates as "ProgressEvent". + const [progressEvent] = errorListener.mock.calls[0] + expect(progressEvent).toBeInstanceOf(ProgressEvent) - // No way to assert the rejection error, because XMLHttpRequest doesn't propagate it. - await expect(getResponse()).rejects.toBeTruthy() + // Request must still exist. + expect(req.status).toBe(0) }) test('does not propagate the forbidden "cookie" header on the bypassed response', async () => { diff --git a/test/modules/fetch/fetch-modify-request.browser.test.ts b/test/modules/fetch/fetch-modify-request.browser.test.ts new file mode 100644 index 00000000..e666f2cf --- /dev/null +++ b/test/modules/fetch/fetch-modify-request.browser.test.ts @@ -0,0 +1,35 @@ +/** + * @jest-environment node + */ +import { pageWith } from 'page-with' +import { HttpServer } from '@open-draft/test-server/http' +import { FetchInterceptor } from '../../../src/interceptors/fetch' + +const server = new HttpServer((app) => { + app.get('/user', (req, res) => { + res.set('X-Appended-Header', req.headers['x-appended-header']).end() + }) +}) + +const interceptor = new FetchInterceptor() + +beforeAll(async () => { + await server.listen() + interceptor.apply() +}) + +afterAll(async () => { + await server.close() + interceptor.dispose() +}) + +it('supports modifying outgoing request headers', async () => { + const context = await pageWith({ + example: require.resolve('./fetch-modify-request.runtime.js'), + }) + + const res = await context.request(server.http.url('/user')) + const headers = await res.allHeaders() + + expect(headers).toHaveProperty('x-appended-header', 'modified') +}) diff --git a/test/modules/fetch/fetch-modify-request.runtime.js b/test/modules/fetch/fetch-modify-request.runtime.js new file mode 100644 index 00000000..ceee5422 --- /dev/null +++ b/test/modules/fetch/fetch-modify-request.runtime.js @@ -0,0 +1,9 @@ +import { FetchInterceptor } from '@mswjs/interceptors/lib/interceptors/fetch' + +const interceptor = new FetchInterceptor() + +interceptor.on('request', async (request) => { + request.headers.set('X-Appended-Header', 'modified') +}) + +interceptor.apply() diff --git a/test/modules/fetch/intercept/fetch.body.runtime.js b/test/modules/fetch/intercept/fetch.body.runtime.js index deb1f027..2191afe4 100644 --- a/test/modules/fetch/intercept/fetch.body.runtime.js +++ b/test/modules/fetch/intercept/fetch.body.runtime.js @@ -2,7 +2,7 @@ import { FetchInterceptor } from '@mswjs/interceptors/lib/interceptors/fetch' const interceptor = new FetchInterceptor() interceptor.on('request', (request) => { - window.requestBody = request.text() + window.requestBody = request.clone().text() }) interceptor.apply() diff --git a/test/modules/fetch/intercept/fetch.browser.runtime.js b/test/modules/fetch/intercept/fetch.browser.runtime.js index e965f33b..9778b68f 100644 --- a/test/modules/fetch/intercept/fetch.browser.runtime.js +++ b/test/modules/fetch/intercept/fetch.browser.runtime.js @@ -1,16 +1,16 @@ import { FetchInterceptor } from '@mswjs/interceptors/lib/interceptors/fetch' const interceptor = new FetchInterceptor() -interceptor.on('request', async (request) => { +interceptor.on('request', async (request, requestId) => { window.dispatchEvent( new CustomEvent('resolver', { detail: { - id: request.id, + id: requestId, method: request.method, - url: request.url.href, - headers: request.headers.all(), + url: request.url, + headers: Object.fromEntries(request.headers.entries()), credentials: request.credentials, - body: await request.text(), + body: await request.clone().text(), }, }) ) diff --git a/test/modules/fetch/intercept/fetch.browser.test.ts b/test/modules/fetch/intercept/fetch.browser.test.ts index a8064c28..4e28ea29 100644 --- a/test/modules/fetch/intercept/fetch.browser.test.ts +++ b/test/modules/fetch/intercept/fetch.browser.test.ts @@ -6,7 +6,6 @@ import { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { Response, pageWith, ScenarioApi } from 'page-with' import { extractRequestFromPage } from '../../../helpers' -import { IsomorphicRequest } from '../../../../src' const httpServer = new HttpServer((app) => { const handleRequest: RequestHandler = (_req, res) => { @@ -31,7 +30,7 @@ async function callFetch( context: ScenarioApi, url: string, init: RequestInit = {} -): Promise<[IsomorphicRequest, Response]> { +): Promise<[Request, Response]> { return Promise.all([ extractRequestFromPage(context.page), context.request(url, init), @@ -56,11 +55,11 @@ describe('HTTP', () => { }, }) - expect(request.method).toEqual('GET') - expect(request.url.href).toEqual(url) - expect(request.headers.get('x-custom-header')).toEqual('yes') - expect(request.credentials).toEqual('same-origin') - expect(await request.text()).toEqual('') + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('') expect(response.status()).toBe(200) expect(response.statusText()).toBe('OK') @@ -78,10 +77,10 @@ describe('HTTP', () => { body: JSON.stringify({ body: 'post' }), }) - expect(request.method).toEqual('POST') - expect(request.url.href).toEqual(url) - expect(request.headers.get('x-custom-header')).toEqual('yes') - expect(request.credentials).toEqual('same-origin') + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') expect(await request.json()).toEqual({ body: 'post' }) expect(response.status()).toBe(200) @@ -100,10 +99,10 @@ describe('HTTP', () => { body: JSON.stringify({ body: 'put' }), }) - expect(request.method).toEqual('PUT') - expect(request.url.href).toEqual(url) - expect(request.headers.get('x-custom-header')).toEqual('yes') - expect(request.credentials).toEqual('same-origin') + expect(request.method).toBe('PUT') + expect(request.url).toBe(url) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') expect(await request.json()).toEqual({ body: 'put' }) expect(response.status()).toBe(200) @@ -122,10 +121,10 @@ describe('HTTP', () => { body: JSON.stringify({ body: 'patch' }), }) - expect(request.method).toEqual('PATCH') - expect(request.url.href).toEqual(url) - expect(request.headers.get('x-custom-header')).toEqual('yes') - expect(request.credentials).toEqual('same-origin') + expect(request.method).toBe('PATCH') + expect(request.url).toBe(url) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') expect(await request.json()).toEqual({ body: 'patch' }) expect(response.status()).toBe(200) @@ -144,10 +143,10 @@ describe('HTTP', () => { body: JSON.stringify({ body: 'delete' }), }) - expect(request.method).toEqual('DELETE') - expect(request.url.href).toEqual(url) - expect(request.headers.get('x-custom-header')).toEqual('yes') - expect(request.credentials).toEqual('same-origin') + expect(request.method).toBe('DELETE') + expect(request.url).toBe(url) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') expect(await request.json()).toEqual({ body: 'delete' }) expect(response.status()).toBe(200) @@ -166,11 +165,11 @@ describe('HTTPS', () => { }, }) - expect(request.method).toEqual('GET') - expect(request.url.href).toEqual(url) - expect(request.headers.get('x-custom-header')).toEqual('yes') - expect(request.credentials).toEqual('same-origin') - expect(await request.text()).toEqual('') + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('') expect(response.status()).toBe(200) expect(response.statusText()).toBe('OK') @@ -188,10 +187,10 @@ describe('HTTPS', () => { body: JSON.stringify({ body: 'post' }), }) - expect(request.method).toEqual('POST') - expect(request.url.href).toEqual(url) - expect(request.headers.get('x-custom-header')).toEqual('yes') - expect(request.credentials).toEqual('same-origin') + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') expect(await request.json()).toEqual({ body: 'post' }) expect(response.status()).toBe(200) @@ -210,10 +209,10 @@ describe('HTTPS', () => { body: JSON.stringify({ body: 'put' }), }) - expect(request.method).toEqual('PUT') - expect(request.url.href).toEqual(url) - expect(request.headers.get('x-custom-header')).toEqual('yes') - expect(request.credentials).toEqual('same-origin') + expect(request.method).toBe('PUT') + expect(request.url).toBe(url) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') expect(await request.json()).toEqual({ body: 'put' }) expect(response.status()).toBe(200) @@ -232,10 +231,10 @@ describe('HTTPS', () => { body: JSON.stringify({ body: 'patch' }), }) - expect(request.method).toEqual('PATCH') - expect(request.url.href).toEqual(url) - expect(request.headers.get('x-custom-header')).toEqual('yes') - expect(request.credentials).toEqual('same-origin') + expect(request.method).toBe('PATCH') + expect(request.url).toBe(url) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') expect(await request.json()).toEqual({ body: 'patch' }) expect(response.status()).toBe(200) @@ -254,10 +253,10 @@ describe('HTTPS', () => { body: JSON.stringify({ body: 'delete' }), }) - expect(request.method).toEqual('DELETE') - expect(request.url.href).toEqual(url) - expect(request.headers.get('x-custom-header')).toEqual('yes') - expect(request.credentials).toEqual('same-origin') + expect(request.method).toBe('DELETE') + expect(request.url).toBe(url) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') expect(await request.json()).toEqual({ body: 'delete' }) expect(response.status()).toBe(200) @@ -274,7 +273,7 @@ test('sets "credentials" to "include" on the isomorphic request when fetch sets credentials: 'include', }) - expect(request.credentials).toEqual('include') + expect(request.credentials).toBe('include') }) test('sets "credentials" to "omit" on the isomorphic request when fetch sets it to "omit"', async () => { @@ -284,5 +283,5 @@ test('sets "credentials" to "omit" on the isomorphic request when fetch sets it credentials: 'omit', }) - expect(request.credentials).toEqual('omit') + expect(request.credentials).toBe('omit') }) diff --git a/test/modules/fetch/intercept/fetch.request.browser.test.ts b/test/modules/fetch/intercept/fetch.request.browser.test.ts index d8fff74a..8083160b 100644 --- a/test/modules/fetch/intercept/fetch.request.browser.test.ts +++ b/test/modules/fetch/intercept/fetch.request.browser.test.ts @@ -5,8 +5,6 @@ import * as path from 'path' import { pageWith } from 'page-with' import { HttpServer } from '@open-draft/test-server/http' import { extractRequestFromPage } from '../../../helpers' -import { anyUuid, headersContaining } from '../../../jest.expect' -import { encodeBuffer } from '../../../../src/utils/bufferUtils' const httpServer = new HttpServer((app) => { app.post('/user', (_req, res) => { @@ -44,15 +42,10 @@ test('intercepts fetch requests constructed via a "Request" instance', async () }, url), ]) - expect(request).toMatchObject({ - id: anyUuid(), - url: new URL(url), - method: 'POST', - headers: headersContaining({ - 'content-type': 'text/plain', - 'x-origin': 'interceptors', - }), - _body: encodeBuffer('hello world'), - credentials: 'same-origin', - }) + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.headers.get('content-type')).toBe('text/plain') + expect(request.headers.get('x-origin')).toBe('interceptors') + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('hello world') }) diff --git a/test/modules/fetch/intercept/fetch.request.test.ts b/test/modules/fetch/intercept/fetch.request.test.ts index f376c5ab..a716ef9d 100644 --- a/test/modules/fetch/intercept/fetch.request.test.ts +++ b/test/modules/fetch/intercept/fetch.request.test.ts @@ -7,7 +7,6 @@ import { HttpRequestEventMap } from '../../../../src' import { fetch } from '../../../helpers' import { anyUuid, headersContaining } from '../../../jest.expect' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { encodeBuffer } from '../../../../src/utils/bufferUtils' const httpServer = new HttpServer((app) => { app.post('/user', (_req, res) => { @@ -52,18 +51,20 @@ test('intercepts fetch requests constructed via a "Request" instance', async () expect(await res.text()).toEqual('mocked') expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(httpServer.http.url('/user')), - headers: headersContaining({ - 'content-type': 'text/plain', - 'user-agent': 'interceptors', - }), - credentials: 'same-origin', - _body: encodeBuffer('hello world'), - respondWith: expect.any(Function), + + const [capturedRequest, requestId] = resolver.mock.calls[0] + + expect(capturedRequest.method).toBe('POST') + expect(capturedRequest.url).toBe(httpServer.http.url('/user')) + expect(capturedRequest.headers).toEqual( + headersContaining({ + 'content-type': 'text/plain', + 'user-agent': 'interceptors', }) ) + expect(capturedRequest.credentials).toBe('same-origin') + expect(await capturedRequest.text()).toBe('hello world') + expect(capturedRequest.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) diff --git a/test/modules/fetch/intercept/fetch.test.ts b/test/modules/fetch/intercept/fetch.test.ts index 43cf4cbf..6e7fbbda 100644 --- a/test/modules/fetch/intercept/fetch.test.ts +++ b/test/modules/fetch/intercept/fetch.test.ts @@ -51,21 +51,17 @@ test('intercepts an HTTP HEAD request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'HEAD', - url: new URL(httpServer.http.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), - }) - ) + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('HEAD') + expect(request.url).toBe(httpServer.http.url('/user?id=123')) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTP GET request', async () => { @@ -76,21 +72,17 @@ test('intercepts an HTTP GET request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(httpServer.http.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), - }) - ) + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.http.url('/user?id=123')) + expect(request.headers.get('x-custom-header')).toBe('yes') + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTP POST request', async () => { @@ -103,22 +95,22 @@ test('intercepts an HTTP POST request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(httpServer.http.url('/user?id=123')), - headers: headersContaining({ - accept: '*/*', - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(JSON.stringify({ body: true })), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('POST') + expect(request.url).toBe(httpServer.http.url('/user?id=123')) + expect(request.headers).toEqual( + headersContaining({ + accept: '*/*', + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(await request.json()).toEqual({ body: true }) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTP PUT request', async () => { @@ -131,21 +123,21 @@ test('intercepts an HTTP PUT request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'PUT', - url: new URL(httpServer.http.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer('request-payload'), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('PUT') + expect(request.url).toBe(httpServer.http.url('/user?id=123')) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('request-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTP DELETE request', async () => { @@ -157,21 +149,21 @@ test('intercepts an HTTP DELETE request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'DELETE', - url: new URL(httpServer.http.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('DELETE') + expect(request.url).toBe(httpServer.http.url('/user?id=123')) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTP PATCH request', async () => { @@ -184,21 +176,21 @@ test('intercepts an HTTP PATCH request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'PATCH', - url: new URL(httpServer.http.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer('request-payload'), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('PATCH') + expect(request.url).toBe(httpServer.http.url('/user?id=123')) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('request-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTPS HEAD request', async () => { @@ -211,21 +203,21 @@ test('intercepts an HTTPS HEAD request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'HEAD', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('HEAD') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTPS GET request', async () => { @@ -237,21 +229,21 @@ test('intercepts an HTTPS GET request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTPS POST request', async () => { @@ -265,21 +257,21 @@ test('intercepts an HTTPS POST request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(JSON.stringify({ body: true })), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('POST') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(await request.json()).toEqual({ body: true }) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTPS PUT request', async () => { @@ -293,21 +285,21 @@ test('intercepts an HTTPS PUT request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'PUT', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer('request-payload'), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('PUT') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('request-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTPS DELETE request', async () => { @@ -320,21 +312,21 @@ test('intercepts an HTTPS DELETE request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'DELETE', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('DELETE') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an HTTPS PATCH request', async () => { @@ -347,19 +339,19 @@ test('intercepts an HTTPS PATCH request', async () => { }) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'PATCH', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('PATCH') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) diff --git a/test/modules/fetch/response/fetch-response-patching.browser.test.ts b/test/modules/fetch/response/fetch-response-patching.browser.test.ts index 2876824b..6af5e241 100644 --- a/test/modules/fetch/response/fetch-response-patching.browser.test.ts +++ b/test/modules/fetch/response/fetch-response-patching.browser.test.ts @@ -4,8 +4,8 @@ import * as path from 'path' import { pageWith } from 'page-with' import { HttpServer } from '@open-draft/test-server/http' -import { FetchInterceptor } from '../../../../src/interceptors/fetch' import { listToHeaders } from 'headers-polyfill' +import { FetchInterceptor } from '../../../../src/interceptors/fetch' declare namespace window { export const interceptor: FetchInterceptor diff --git a/test/modules/fetch/response/fetch-response-patching.runtime.js b/test/modules/fetch/response/fetch-response-patching.runtime.js index 3d4821a4..87cebb1d 100644 --- a/test/modules/fetch/response/fetch-response-patching.runtime.js +++ b/test/modules/fetch/response/fetch-response-patching.runtime.js @@ -3,21 +3,15 @@ import { FetchInterceptor } from '@mswjs/interceptors/lib/interceptors/fetch' const interceptor = new FetchInterceptor() interceptor.on('request', async (request) => { - if (request.url.pathname === '/mocked') { + const url = new URL(request.url) + + if (url.pathname === '/mocked') { await new Promise((resolve) => setTimeout(resolve, 0)) const originalResponse = await fetch(window.originalUrl) const originalText = await originalResponse.text() - request.respondWith({ - status: originalResponse.status, - statusText: originalResponse.statusText, - headers: { - 'X-Custom-Header': - originalResponse.headers.get('X-Custom-Header') || '', - }, - body: `${originalText} world`, - }) + request.respondWith(new Response(`${originalText} world`, originalResponse)) } }) diff --git a/test/modules/fetch/response/fetch.browser.runtime.js b/test/modules/fetch/response/fetch.browser.runtime.js index 0193da8a..9a0f6529 100644 --- a/test/modules/fetch/response/fetch.browser.runtime.js +++ b/test/modules/fetch/response/fetch.browser.runtime.js @@ -5,14 +5,16 @@ const interceptor = new FetchInterceptor() interceptor.on('request', (request) => { const { serverHttpUrl, serverHttpsUrl } = window - if ([serverHttpUrl, serverHttpsUrl].includes(request.url.href)) { - request.respondWith({ - status: 201, - headers: { - 'Content-Type': 'application/hal+json', - }, - body: JSON.stringify({ mocked: true }), - }) + if ([serverHttpUrl, serverHttpsUrl].includes(request.url)) { + request.respondWith( + new Response(JSON.stringify({ mocked: true }), { + status: 201, + statusText: 'Created', + headers: { + 'Content-Type': 'application/hal+json', + }, + }) + ) } }) diff --git a/test/modules/fetch/response/fetch.browser.test.ts b/test/modules/fetch/response/fetch.browser.test.ts index 104d200a..e698cb52 100644 --- a/test/modules/fetch/response/fetch.browser.test.ts +++ b/test/modules/fetch/response/fetch.browser.test.ts @@ -78,7 +78,7 @@ test('responds to an HTTP request handled in the resolver', async () => { expect(response.url).toBe(httpServer.http.url('/')) expect(response.type).toBe('default') expect(response.status).toBe(201) - expect(response.statusText).toBe('OK') + expect(response.statusText).toBe('Created') expect(headers.get('content-type')).toBe('application/hal+json') expect(headers).not.toHaveProperty('map') expect(headers.has('map')).toBe(false) @@ -139,7 +139,7 @@ test('responds to an HTTPS request handled in the resolver', async () => { expect(response.url).toBe(httpServer.https.url('/')) expect(response.type).toBe('default') expect(response.status).toBe(201) - expect(response.statusText).toBe('OK') + expect(response.statusText).toBe('Created') expect(headers.get('content-type')).toBe('application/hal+json') expect(headers).not.toHaveProperty('map') expect(headers.has('map')).toBe(false) diff --git a/test/modules/fetch/response/fetch.test.ts b/test/modules/fetch/response/fetch.test.ts index 52f1fd02..5e9db1cf 100644 --- a/test/modules/fetch/response/fetch.test.ts +++ b/test/modules/fetch/response/fetch.test.ts @@ -4,6 +4,7 @@ import fetch from 'node-fetch' import { HttpServer, httpsAgent } from '@open-draft/test-server/http' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { Response } from '@remix-run/web-fetch' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { @@ -16,16 +17,15 @@ const httpServer = new HttpServer((app) => { const interceptor = new ClientRequestInterceptor() interceptor.on('request', (request) => { - if ( - [httpServer.http.url(), httpServer.https.url()].includes(request.url.href) - ) { - request.respondWith({ - status: 201, - headers: { - 'Content-Type': 'application/hal+json', - }, - body: JSON.stringify({ mocked: true }), - }) + if ([httpServer.http.url(), httpServer.https.url()].includes(request.url)) { + request.respondWith( + new Response(JSON.stringify({ mocked: true }), { + status: 201, + headers: { + 'Content-Type': 'application/hal+json', + }, + }) + ) } }) diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts new file mode 100644 index 00000000..5325a925 --- /dev/null +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -0,0 +1,36 @@ +/** + * @jest-environment node + */ +import * as http from 'http' +import { HttpServer } from '@open-draft/test-server/http' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { waitForClientRequest } from '../../../helpers' + +const server = new HttpServer((app) => { + app.get('/user', (req, res) => { + res.set('x-appended-header', req.headers['x-appended-header']).end() + }) +}) + +const interceptor = new ClientRequestInterceptor() + +beforeAll(async () => { + await server.listen() + interceptor.apply() +}) + +afterAll(async () => { + await server.close() + interceptor.dispose() +}) + +it('allows modifying the outgoing request headers', async () => { + interceptor.on('request', (request) => { + request.headers.set('X-Appended-Header', 'modified') + }) + + const req = http.get(server.http.url('/user')) + const { text, res } = await waitForClientRequest(req) + + expect(res.headers['x-appended-header']).toBe('modified') +}) diff --git a/test/modules/http/compliance/http-rate-limit.test.ts b/test/modules/http/compliance/http-rate-limit.test.ts index 93499d3d..9a106129 100644 --- a/test/modules/http/compliance/http-rate-limit.test.ts +++ b/test/modules/http/compliance/http-rate-limit.test.ts @@ -1,6 +1,7 @@ import * as http from 'http' import rateLimit from 'express-rate-limit' import { HttpServer } from '@open-draft/test-server/http' +import { Response } from '@remix-run/web-fetch' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { @@ -22,15 +23,15 @@ const httpServer = new HttpServer((app) => { const interceptor = new ClientRequestInterceptor() interceptor.on('request', (request) => { - if (!request.url.searchParams.has('mock')) { + const url = new URL(request.url) + + if (!url.searchParams.has('mock')) { return } - request.respondWith({ - status: 403, - statusText: 'Forbidden', - body: 'mocked-body', - }) + request.respondWith( + new Response('mocked-body', { status: 403, statusText: 'Forbidden' }) + ) }) const handleLimitReached = jest.fn() diff --git a/test/modules/http/compliance/http-req-callback.test.ts b/test/modules/http/compliance/http-req-callback.test.ts index 1fa3ed00..ff71b274 100644 --- a/test/modules/http/compliance/http-req-callback.test.ts +++ b/test/modules/http/compliance/http-req-callback.test.ts @@ -4,6 +4,7 @@ import { IncomingMessage } from 'http' import * as https from 'https' import { HttpServer, httpsAgent } from '@open-draft/test-server/http' +import { Response } from '@remix-run/web-fetch' import { getRequestOptionsByUrl } from '../../../../src/utils/getRequestOptionsByUrl' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' @@ -15,15 +16,16 @@ const httpServer = new HttpServer((app) => { const interceptor = new ClientRequestInterceptor() interceptor.on('request', (request) => { - if ([httpServer.https.url('/get')].includes(request.url.href)) { + if ([httpServer.https.url('/get')].includes(request.url)) { return } - request.respondWith({ - status: 403, - statusText: 'Forbidden', - body: 'mocked-body', - }) + request.respondWith( + new Response('mocked-body', { + status: 403, + statusText: 'Forbidden', + }) + ) }) beforeAll(async () => { diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index 3aefd809..1fb81af1 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -18,11 +18,11 @@ const interceptedRequestBody = jest.fn() const interceptor = new ClientRequestInterceptor() interceptor.on('request', async (request) => { - interceptedRequestBody(await request.text()) + interceptedRequestBody(await request.clone().text()) }) function getInternalRequestBody(req: http.ClientRequest): Buffer { - return Buffer.concat((req as NodeClientRequest).requestBody) + return Buffer.from((req as NodeClientRequest).requestBuffer || '') } beforeAll(async () => { diff --git a/test/modules/http/compliance/http-res-read-multiple-times.test.ts b/test/modules/http/compliance/http-res-read-multiple-times.test.ts index f27b5b0a..5057efe8 100644 --- a/test/modules/http/compliance/http-res-read-multiple-times.test.ts +++ b/test/modules/http/compliance/http-res-read-multiple-times.test.ts @@ -5,7 +5,7 @@ */ import http, { IncomingMessage } from 'http' import { HttpServer } from '@open-draft/test-server/http' -import { HttpRequestEventMap, IsomorphicResponse } from '../../../../src' +import { HttpRequestEventMap } from '../../../../src' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { @@ -68,21 +68,23 @@ test('allows reading the response body after it has been read internally', async }) } - const request = await makeRequest() - const capturedResponse = await new Promise((resolve) => { - interceptor.on('response', (_, response) => resolve(response)) + const untilCapturedResponse = new Promise((resolve) => { + interceptor.on('response', (response) => resolve(response)) }) + const request = await makeRequest() + const capturedResponse = await untilCapturedResponse // Original response. - expect(request.response.statusCode).toEqual(200) - expect(request.response.statusMessage).toEqual('OK') + expect(request.response.statusCode).toBe(200) + expect(request.response.statusMessage).toBe('OK') expect(request.response.headers).toHaveProperty('x-powered-by', 'Express') const text = await request.toText() - expect(text).toEqual('user-body') + expect(text).toBe('user-body') - // Isomorphic response (callback). - expect(capturedResponse.status).toEqual(200) - expect(capturedResponse.statusText).toEqual('OK') - expect(capturedResponse.headers.get('x-powered-by')).toEqual('Express') - expect(capturedResponse.body).toEqual('user-body') + // Response from the "response" callback. + expect(capturedResponse.status).toBe(200) + expect(capturedResponse.statusText).toBe('OK') + expect(capturedResponse.headers.get('x-powered-by')).toBe('Express') + expect(capturedResponse.bodyUsed).toBe(false) + expect(await capturedResponse.text()).toBe('user-body') }) diff --git a/test/modules/http/compliance/http-res-set-encoding.test.ts b/test/modules/http/compliance/http-res-set-encoding.test.ts index b7fe5d16..4e2dbad3 100644 --- a/test/modules/http/compliance/http-res-set-encoding.test.ts +++ b/test/modules/http/compliance/http-res-set-encoding.test.ts @@ -3,6 +3,7 @@ */ import * as http from 'http' import { HttpServer } from '@open-draft/test-server/http' +import { Response } from '@remix-run/web-fetch' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { @@ -13,17 +14,20 @@ const httpServer = new HttpServer((app) => { const interceptor = new ClientRequestInterceptor() interceptor.on('request', (request) => { - if (!request.url.searchParams.has('mock')) { + const url = new URL(request.url) + + if (!url.searchParams.has('mock')) { return } - request.respondWith({ - status: 200, - headers: { - 'Content-Type': 'text/plain', - }, - body: 'hello world', - }) + request.respondWith( + new Response('hello world', { + status: 200, + headers: { + 'Content-Type': 'text/plain', + }, + }) + ) }) function encode(text: string, encoding: BufferEncoding): string { diff --git a/test/modules/http/compliance/https-constructor.test.ts b/test/modules/http/compliance/https-constructor.test.ts index 88e69118..1c5bde8b 100644 --- a/test/modules/http/compliance/https-constructor.test.ts +++ b/test/modules/http/compliance/https-constructor.test.ts @@ -17,7 +17,6 @@ const interceptor = new ClientRequestInterceptor() beforeAll(async () => { await httpServer.listen() - interceptor.apply() }) diff --git a/test/modules/http/http-performance.test.ts b/test/modules/http/http-performance.test.ts index e30aedca..7b1acff3 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -2,6 +2,7 @@ * @jest-environment node */ import { HttpServer } from '@open-draft/test-server/http' +import { Response } from '@remix-run/web-fetch' import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' import { httpGet, PromisifiedResponse } from '../../helpers' @@ -31,13 +32,11 @@ const httpServer = new HttpServer((app) => { const interceptor = new ClientRequestInterceptor() interceptor.on('request', (request) => { - if (request.url.pathname.startsWith('/user')) { - const id = request.url.searchParams.get('id') + const url = new URL(request.url) - request.respondWith({ - status: 200, - body: `mocked ${id}`, - }) + if (url.pathname.startsWith('/user')) { + const id = url.searchParams.get('id') + request.respondWith(new Response(`mocked ${id}`)) } }) diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index 5fd5cbb8..f91cd1ac 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -7,7 +7,6 @@ import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientReq import { anyUuid, headersContaining } from '../../../jest.expect' import { waitForClientRequest } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src' -import { encodeBuffer } from '../../../../src/utils/bufferUtils' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -44,22 +43,24 @@ test('intercepts an http.get request', async () => { const { text } = await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) - expect(await text()).toEqual('user-body') + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) + + // Must receive the original response. + expect(await text()).toBe('user-body') }) test('intercepts an http.get request given RequestOptions without a protocol', async () => { @@ -73,18 +74,20 @@ test('intercepts an http.get request given RequestOptions without a protocol', a const { text } = await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(httpServer.http.url('/user?id=123')), - headers: headersContaining({}), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), - }) + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.http.url('/user?id=123')) + expect(request.headers.get('host')).toBe( + `${httpServer.http.address.host}:${httpServer.http.address.port}` ) - expect(await text()).toEqual('user-body') + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) + + // Must receive the original response. + expect(await text()).toBe('user-body') }) diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index c8e53efd..d6197009 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -8,7 +8,6 @@ import { anyUuid, headersContaining } from '../../../jest.expect' import { waitForClientRequest } from '../../../helpers' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { HttpRequestEventMap } from '../../../../src' -import { encodeBuffer } from '../../../../src/utils/bufferUtils' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (_req, res) => { @@ -27,7 +26,6 @@ interceptor.on('request', resolver) beforeAll(async () => { await httpServer.listen() - interceptor.apply() }) @@ -52,21 +50,21 @@ test('intercepts a HEAD request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - url: new URL(url), - method: 'HEAD', - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('HEAD') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts a GET request', async () => { @@ -81,21 +79,21 @@ test('intercepts a GET request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts a POST request', async () => { @@ -111,21 +109,21 @@ test('intercepts a POST request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer('post-payload'), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('post-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts a PUT request', async () => { @@ -141,21 +139,21 @@ test('intercepts a PUT request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'PUT', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer('put-payload'), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('PUT') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('put-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts a PATCH request', async () => { @@ -171,21 +169,21 @@ test('intercepts a PATCH request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'PATCH', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer('patch-payload'), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('PATCH') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('patch-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts a DELETE request', async () => { @@ -200,21 +198,21 @@ test('intercepts a DELETE request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'DELETE', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('DELETE') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an http.request given RequestOptions without a protocol', async () => { @@ -230,17 +228,13 @@ test('intercepts an http.request given RequestOptions without a protocol', async expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(httpServer.http.url('/user?id=123')), - headers: headersContaining({}), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), - }) - ) + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.http.url('/user?id=123')) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index b7c0b370..b280baf9 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -7,7 +7,6 @@ import { anyUuid, headersContaining } from '../../../jest.expect' import { waitForClientRequest } from '../../../helpers' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { HttpRequestEventMap } from '../../../../src' -import { encodeBuffer } from '../../../../src/utils/bufferUtils' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -21,7 +20,6 @@ interceptor.on('request', resolver) beforeAll(async () => { await httpServer.listen() - interceptor.apply() }) @@ -45,21 +43,21 @@ test('intercepts a GET request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(url), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(request.headers).toEqual( + headersContaining({ + 'x-custom-header': 'yes', }) ) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an https.get request given RequestOptions without a protocol', async () => { @@ -75,17 +73,17 @@ test('intercepts an https.get request given RequestOptions without a protocol', await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({}), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), - }) + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.headers.get('host')).toBe( + `${httpServer.https.address.host}:${httpServer.https.address.port}` ) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index 6b072627..e36cdcff 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -29,7 +29,6 @@ interceptor.on('request', resolver) beforeAll(async () => { await httpServer.listen() - interceptor.apply() }) @@ -55,21 +54,16 @@ test('intercepts a HEAD request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'HEAD', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), - }) - ) + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('HEAD') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts a GET request', async () => { @@ -85,21 +79,16 @@ test('intercepts a GET request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), - }) - ) + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts a POST request', async () => { @@ -116,21 +105,16 @@ test('intercepts a POST request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'POST', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer('post-payload'), - respondWith: expect.any(Function), - }) - ) + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('POST') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('post-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts a PUT request', async () => { @@ -147,21 +131,16 @@ test('intercepts a PUT request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'PUT', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer('put-payload'), - respondWith: expect.any(Function), - }) - ) + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('PUT') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('put-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts a PATCH request', async () => { @@ -178,21 +157,16 @@ test('intercepts a PATCH request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'PATCH', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer('patch-payload'), - respondWith: expect.any(Function), - }) - ) + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('PATCH') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.credentials).toBe('same-origin') + expect(await request.text()).toBe('patch-payload') + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts a DELETE request', async () => { @@ -208,21 +182,16 @@ test('intercepts a DELETE request', async () => { await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'DELETE', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({ - 'x-custom-header': 'yes', - }), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), - }) - ) + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('DELETE') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) test('intercepts an http.request request given RequestOptions without a protocol', async () => { @@ -236,17 +205,14 @@ test('intercepts an http.request request given RequestOptions without a protocol await waitForClientRequest(req) expect(resolver).toHaveBeenCalledTimes(1) - expect(resolver).toHaveBeenCalledWith< - Parameters - >( - expect.objectContaining({ - id: anyUuid(), - method: 'GET', - url: new URL(httpServer.https.url('/user?id=123')), - headers: headersContaining({}), - credentials: 'same-origin', - _body: encodeBuffer(''), - respondWith: expect.any(Function), - }) - ) + + const [request, requestId] = resolver.mock.calls[0] + + expect(request.method).toBe('GET') + expect(request.url).toBe(httpServer.https.url('/user?id=123')) + expect(request.credentials).toBe('same-origin') + expect(request.body).toBe(null) + expect(request.respondWith).toBeInstanceOf(Function) + + expect(requestId).toEqual(anyUuid()) }) diff --git a/test/modules/http/regressions/http-concurrent-different-response-source.test.ts b/test/modules/http/regressions/http-concurrent-different-response-source.test.ts index a4170ac7..5c4a5161 100644 --- a/test/modules/http/regressions/http-concurrent-different-response-source.test.ts +++ b/test/modules/http/regressions/http-concurrent-different-response-source.test.ts @@ -2,6 +2,7 @@ * @jest-environment node */ import { HttpServer } from '@open-draft/test-server/http' +import { Response } from '@remix-run/web-fetch' import { httpGet } from '../../../helpers' import { sleep } from '../../../../test/helpers' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' @@ -21,10 +22,7 @@ interceptor.on('request', async (request) => { await sleep(250) - request.respondWith({ - status: 201, - body: 'mocked-response', - }) + request.respondWith(new Response('mocked-response', { status: 201 })) }) beforeAll(async () => { diff --git a/test/modules/http/regressions/http-concurrent-same-host.test.ts b/test/modules/http/regressions/http-concurrent-same-host.test.ts index 145b1b4f..4498db81 100644 --- a/test/modules/http/regressions/http-concurrent-same-host.test.ts +++ b/test/modules/http/regressions/http-concurrent-same-host.test.ts @@ -3,17 +3,15 @@ * @see https://github.com/mswjs/interceptors/issues/2 */ import * as http from 'http' -import { IsomorphicRequest } from '../../../../src' +import { Response } from '@remix-run/web-fetch' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -let requests: IsomorphicRequest[] = [] +let requests: Array = [] const interceptor = new ClientRequestInterceptor() interceptor.on('request', (request) => { requests.push(request) - request.respondWith({ - status: 200, - }) + request.respondWith(new Response()) }) beforeAll(() => { @@ -57,14 +55,13 @@ test('resolves multiple concurrent requests to the same host independently', asy ]) for (const request of requests) { - const expectedHeaderValue = request.url.searchParams.get('header') + const url = new URL(request.url) + const expectedHeaderValue = url.searchParams.get('header') if (expectedHeaderValue) { - expect(request.headers.get('x-custom-header')).toEqual( - expectedHeaderValue - ) + expect(request.headers.get('x-custom-header')).toBe(expectedHeaderValue) } else { - expect(request.headers.has('x-custom-header')).toEqual(false) + expect(request.headers.has('x-custom-header')).toBe(false) } } }) diff --git a/test/modules/http/regressions/http-socket-timeout.ts b/test/modules/http/regressions/http-socket-timeout.ts index 2812a554..30158440 100644 --- a/test/modules/http/regressions/http-socket-timeout.ts +++ b/test/modules/http/regressions/http-socket-timeout.ts @@ -6,6 +6,7 @@ */ import * as http from 'http' import { HttpServer } from '@open-draft/test-server/http' +import { Response } from '@remix-run/web-fetch' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' jest.setTimeout(5000) @@ -18,10 +19,7 @@ const httpServer = new HttpServer((app) => { const interceptor = new ClientRequestInterceptor() interceptor.on('request', (request) => { - request.respondWith({ - status: 301, - body: 'Hello world', - }) + request.respondWith(new Response('hello world', { status: 301 })) }) beforeAll(async () => { diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index bf24b059..2b222de9 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -3,6 +3,7 @@ */ import * as http from 'http' import * as https from 'https' +import { Response } from '@remix-run/web-fetch' import { HttpServer, httpsAgent } from '@open-draft/test-server/http' import { waitForClientRequest } from '../../../helpers' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' @@ -18,18 +19,21 @@ const httpServer = new HttpServer((app) => { const interceptor = new ClientRequestInterceptor() interceptor.on('request', (request) => { - if (request.url.pathname === '/non-existing') { - request.respondWith({ - status: 301, - statusText: 'Moved Permanently', - headers: { - 'Content-Type': 'text/plain', - }, - body: 'mocked', - }) + const url = new URL(request.url) + + if (url.pathname === '/non-existing') { + request.respondWith( + new Response('mocked', { + status: 301, + statusText: 'Moved Permanently', + headers: { + 'Content-Type': 'text/plain', + }, + }) + ) } - if (request.url.href === 'http://error.me/') { + if (url.href === 'http://error.me/') { throw new Error('Custom exception message') } }) diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 787092ec..249c242b 100644 --- a/test/modules/http/response/http-response-patching.test.ts +++ b/test/modules/http/response/http-response-patching.test.ts @@ -3,11 +3,11 @@ */ import * as http from 'http' import { HttpServer } from '@open-draft/test-server/http' -import { BatchInterceptor, MockedResponse } from '../../../../src' +import { Response } from '@remix-run/web-fetch' +import { BatchInterceptor } from '../../../../src' import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { sleep, waitForClientRequest } from '../../../helpers' -import { InteractiveIsomorphicRequest } from '../../../../src/InteractiveIsomorphicRequest' const server = new HttpServer((app) => { app.get('/original', async (req, res) => { @@ -23,10 +23,10 @@ const interceptor = new BatchInterceptor({ ], }) -async function getResponse( - request: InteractiveIsomorphicRequest -): Promise { - switch (request.url.pathname) { +async function getResponse(request: Request): Promise { + const url = new URL(request.url) + + switch (url.pathname) { case '/mocked': { return new Promise(async (resolve) => { // Defer the resolution of the promise to the next tick. @@ -36,14 +36,22 @@ async function getResponse( const originalRequest = http.get(server.http.url('/original')) const { res, text } = await waitForClientRequest(originalRequest) - resolve({ - status: res.statusCode, - statusText: res.statusMessage, - headers: { - 'X-Custom-Header': res.headers['x-custom-header'] || '', - }, - body: (await text()) + ' world', - }) + const getHeader = (name: string): string | undefined => { + const value = res.headers[name] + return Array.isArray(value) ? value.join(', ') : value + } + + const responseText = (await text()) + ' world' + + resolve( + new Response(responseText, { + status: res.statusCode, + statusText: res.statusMessage, + headers: { + 'X-Custom-Header': getHeader('x-custom-header') || '', + }, + }) + ) }) } } diff --git a/test/third-party/axios.test.ts b/test/third-party/axios.test.ts index 3e27d29f..ad3896b1 100644 --- a/test/third-party/axios.test.ts +++ b/test/third-party/axios.test.ts @@ -4,6 +4,7 @@ import axios from 'axios' import { HttpServer } from '@open-draft/test-server/http' import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' +import { Response } from '@remix-run/web-fetch' const httpServer = new HttpServer((app) => { app.get('/books', (req, res) => { @@ -22,17 +23,23 @@ const httpServer = new HttpServer((app) => { const interceptor = new ClientRequestInterceptor() interceptor.on('request', (request) => { - if (request.url.pathname === '/user') { - request.respondWith({ - status: 200, - headers: { - 'content-type': 'application/json', - 'x-header': 'yes', - }, - body: JSON.stringify({ - mocked: true, - }), - }) + const url = new URL(request.url) + + if (url.pathname === '/user') { + request.respondWith( + new Response( + JSON.stringify({ + mocked: true, + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-header': 'yes', + }, + } + ) + ) } }) diff --git a/test/third-party/follow-redirect-http.test.ts b/test/third-party/follow-redirect-http.test.ts index c634c771..0370a307 100644 --- a/test/third-party/follow-redirect-http.test.ts +++ b/test/third-party/follow-redirect-http.test.ts @@ -13,11 +13,16 @@ const interceptor = new ClientRequestInterceptor() interceptor.on('request', resolver) const server = new HttpServer((app) => { - app.get('/resource', (req, res) => { - res.status(301).set('Location', server.https.url('/user')).end() + app.post('/resource', (req, res) => { + /** + * @note Respond with the 307 status code so the redirect + * request would use the same method as the original request. + * @see https://github.com/follow-redirects/follow-redirects/issues/121 + */ + res.status(307).set('Location', server.https.url('/user')).end() }) - app.get('/user', (req, res) => { + app.post('/user', (req, res) => { res.status(200).send('hello from the server') }) }) @@ -43,7 +48,7 @@ test('intercepts a POST request issued by "follow-redirects"', async () => { const catchResponseUrl = jest.fn() const req = https.request( { - method: 'GET', + method: 'POST', hostname: address.host, port: address.port, path: '/resource', @@ -60,14 +65,15 @@ test('intercepts a POST request issued by "follow-redirects"', async () => { req.end(payload) - const { res, text } = await waitForClientRequest(req as any) + const { text } = await waitForClientRequest(req as any) + expect(resolver).toHaveBeenCalledTimes(2) // Intercepted initial request. const [initialRequest] = resolver.mock.calls[0] - expect(initialRequest.method).toBe('GET') - expect(initialRequest.url.href).toBe(server.https.url('/resource')) + expect(initialRequest.method).toBe('POST') + expect(initialRequest.url).toBe(server.https.url('/resource')) expect(initialRequest.credentials).toBe('same-origin') expect(initialRequest.headers.get('Content-Type')).toBe('application/json') expect(initialRequest.headers.get('Content-Length')).toBe('23') @@ -76,8 +82,8 @@ test('intercepts a POST request issued by "follow-redirects"', async () => { // Intercepted redirect request (issued by "follow-redirects"). const [redirectedRequest] = resolver.mock.calls[1] - expect(redirectedRequest.method).toBe('GET') - expect(redirectedRequest.url.href).toBe(server.https.url('/user')) + expect(redirectedRequest.method).toBe('POST') + expect(redirectedRequest.url).toBe(server.https.url('/user')) expect(redirectedRequest.credentials).toBe('same-origin') expect(redirectedRequest.headers.get('Content-Type')).toBe('application/json') expect(redirectedRequest.headers.get('Content-Length')).toBe('23') diff --git a/test/third-party/got.test.ts b/test/third-party/got.test.ts index 997d7981..99c8c971 100644 --- a/test/third-party/got.test.ts +++ b/test/third-party/got.test.ts @@ -4,6 +4,7 @@ import got from 'got' import { HttpServer } from '@open-draft/test-server/http' import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' +import { Response } from '@remix-run/web-fetch' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -14,10 +15,7 @@ const httpServer = new HttpServer((app) => { const interceptor = new ClientRequestInterceptor() interceptor.on('request', (request) => { if (request.url.toString() === httpServer.http.url('/test')) { - request.respondWith({ - status: 200, - body: 'mocked-body', - }) + request.respondWith(new Response('mocked-body')) } }) diff --git a/test/third-party/supertest.test.ts b/test/third-party/supertest.test.ts index 2608b57f..9da55c76 100644 --- a/test/third-party/supertest.test.ts +++ b/test/third-party/supertest.test.ts @@ -4,9 +4,8 @@ import express from 'express' import supertest from 'supertest' import { ClientRequestInterceptor } from '../../src/interceptors/ClientRequest' -import { IsomorphicRequest } from '../../src' -let requests: IsomorphicRequest[] = [] +let requests: Array = [] const interceptor = new ClientRequestInterceptor() interceptor.on('request', (request) => { diff --git a/tsconfig.json b/tsconfig.json index d6107c0c..106d42ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "moduleResolution": "node", "removeComments": false, "esModuleInterop": true, - "downlevelIteration": true + "downlevelIteration": true, + "lib": ["dom", "dom.iterable"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "**/*.test.*"] diff --git a/yarn.lock b/yarn.lock index 4c0cfca8..63ba652f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -868,6 +868,41 @@ semver "^7.3.7" yargs "^17.4.1" +"@remix-run/web-blob@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@remix-run/web-blob/-/web-blob-3.0.4.tgz#99c67b9d0fb641bd0c07d267fd218ae5aa4ae5ed" + integrity sha512-AfegzZvSSDc+LwnXV+SwROTrDtoLiPxeFW+jxgvtDAnkuCX1rrzmVJ6CzqZ1Ai0bVfmJadkG5GxtAfYclpPmgw== + dependencies: + "@remix-run/web-stream" "^1.0.0" + web-encoding "1.1.5" + +"@remix-run/web-fetch@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@remix-run/web-fetch/-/web-fetch-4.3.1.tgz#afc88e133bed1a6aecb9d09b1b02127fc844da47" + integrity sha512-TOpuJo3jI7/qJAY0yTnvNJRQl4hlWAOHUHYKAbEddcLFLydv+0/WGT6OaIurJKsBFJJTjooe3FbKiP74sUIB/g== + dependencies: + "@remix-run/web-blob" "^3.0.4" + "@remix-run/web-form-data" "^3.0.3" + "@remix-run/web-stream" "^1.0.3" + "@web3-storage/multipart-parser" "^1.0.0" + abort-controller "^3.0.0" + data-uri-to-buffer "^3.0.1" + mrmime "^1.0.0" + +"@remix-run/web-form-data@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@remix-run/web-form-data/-/web-form-data-3.0.3.tgz#f89a7f971aaf1084d2da87affbb7f4e01c32b8ce" + integrity sha512-wL4veBtVPazSpXfPMzrbmeV3IxuxCfcQYPerQ8BXRO5ahAEVw23tv7xS+yoX0XDO5j+vpRaSbhHJK1H5gF7eYQ== + dependencies: + web-encoding "1.1.5" + +"@remix-run/web-stream@^1.0.0", "@remix-run/web-stream@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@remix-run/web-stream/-/web-stream-1.0.3.tgz#3284a6a45675d1455c4d9c8f31b89225c9006438" + integrity sha512-wlezlJaA5NF6SsNMiwQnnAW6tnPzQ5I8qk0Y0pSohm0eHKa2FQ1QhEKLVVcDDu02TmkfHgnux0igNfeYhDOXiA== + dependencies: + web-streams-polyfill "^3.1.1" + "@sindresorhus/is@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.0.0.tgz#2ff674e9611b45b528896d820d3d7a812de2f0e4" @@ -1315,6 +1350,11 @@ dependencies: "@types/node" "*" +"@web3-storage/multipart-parser@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@web3-storage/multipart-parser/-/multipart-parser-1.0.0.tgz#6b69dc2a32a5b207ba43e556c25cc136a56659c4" + integrity sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw== + "@webassemblyjs/ast@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" @@ -1469,6 +1509,13 @@ abab@^2.0.3, abab@^2.0.5: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.4, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -2273,6 +2320,11 @@ dargs@^7.0.0: resolved "https://registry.yarnpkg.com/dargs/-/dargs-7.0.0.tgz#04015c41de0bcb69ec84050f3d9be0caf8d6d5cc" integrity sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg== +data-uri-to-buffer@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz#594b8973938c5bc2c33046535785341abc4f3636" + integrity sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og== + data-urls@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" @@ -2641,6 +2693,11 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" @@ -4512,6 +4569,11 @@ mri@1.1.4: resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.4.tgz#7cb1dd1b9b40905f1fac053abe25b6720f44744a" integrity sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w== +mrmime@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27" + integrity sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -6088,10 +6150,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@4.3.5: - version "4.3.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" - integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== +typescript@4.4.4: + version "4.4.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.4.tgz#2cd01a1a1f160704d3101fd5a58ff0f9fcb8030c" + integrity sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA== typescript@^4.4.3: version "4.5.4" @@ -6213,7 +6275,7 @@ watchpack@^2.2.0: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" -web-encoding@^1.1.5: +web-encoding@1.1.5, web-encoding@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/web-encoding/-/web-encoding-1.1.5.tgz#fc810cf7667364a6335c939913f5051d3e0c4864" integrity sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA== @@ -6222,6 +6284,11 @@ web-encoding@^1.1.5: optionalDependencies: "@zxing/text-encoding" "0.9.0" +web-streams-polyfill@^3.1.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" + integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"