diff --git a/.github/ISSUE_TEMPLATE/02-issue-nodejs.yml b/.github/ISSUE_TEMPLATE/02-issue-nodejs.yml index 1f50812c6..9d82472c0 100644 --- a/.github/ISSUE_TEMPLATE/02-issue-nodejs.yml +++ b/.github/ISSUE_TEMPLATE/02-issue-nodejs.yml @@ -23,14 +23,14 @@ body: options: - label: I'm using the [latest](https://github.com/mswjs/msw/releases/latest) `msw` version required: true - - label: I'm using Node.js version 14 or higher + - label: I'm using Node.js version 18 or higher required: true - type: input attributes: label: Node.js version description: Specify which Node.js version you're using (`node -v`). - placeholder: i.e. v16.14.0 + placeholder: i.e. v18.14.0 validations: required: true diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index 85c1a24eb..2cc378ed1 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -49,7 +49,7 @@ jobs: strategy: fail-fast: false matrix: - ts: ['4.4', '4.5', '4.6', '4.7', '4.8', '4.9', '5.0', '5.1', '5.2'] + ts: ['4.7', '4.8', '4.9', '5.0', '5.1', '5.2'] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/README.md b/README.md index 3480e72fb..3ba4a5c87 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ +> **MSW 2.0 is finally here! 🎉** Read the [Release notes](https://github.com/mswjs/msw/releases/tag/v2.0.0) and please follow the [**Migration guidelines**](https://mswjs.io/docs/migrations/1.x-to-2.x) to upgrade. If you're having any questions while upgrading, please reach out in our [Discord server](https://kettanaito.com/discord). +> +> We've also recorded the most comprehensive introduction to MSW ever. Learn how to mock APIs like a pro in our official video course: + + + Mock REST and GraphQL APIs with Mock Service Worker + + +

@@ -21,11 +30,13 @@
+
+ ## Features - **Seamless**. A dedicated layer of requests interception at your disposal. Keep your application's code and tests unaware of whether something is mocked or not. - **Deviation-free**. Request the same production resources and test the actual behavior of your app. Augment an existing API, or design it as you go when there is none. -- **Familiar & Powerful**. Use [Express](https://github.com/expressjs/express)-like routing syntax to capture requests. Use parameters, wildcards, and regular expressions to match requests, and respond with necessary status codes, headers, cookies, delays, or completely custom resolvers. +- **Familiar & Powerful**. Use [Express](https://github.com/expressjs/express)-like routing syntax to intercept requests. Use parameters, wildcards, and regular expressions to match requests, and respond with necessary status codes, headers, cookies, delays, or completely custom resolvers. --- @@ -38,8 +49,7 @@ This README will give you a brief overview on the library but there's no better place to start with Mock Service Worker than its official documentation. - [Documentation](https://mswjs.io/docs) -- [**Getting started**](https://mswjs.io/docs/getting-started/install) -- [Recipes](https://mswjs.io/docs/recipes) +- [**Getting started**](https://mswjs.io/docs/getting-started) - [FAQ](https://mswjs.io/docs/faq) ## Examples @@ -48,12 +58,12 @@ This README will give you a brief overview on the library but there's no better ## Browser -- [Learn more about using MSW in a browser](https://mswjs.io/docs/getting-started/integrate/browser) +- [Learn more about using MSW in a browser](https://mswjs.io/docs/integrations/browser) - [`setupWorker` API](https://mswjs.io/docs/api/setup-worker) ### How does it work? -In-browser usage is what sets Mock Service Worker apart from other tools. Utilizing the [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API), which can intercept requests for the purpose of caching, Mock Service Worker responds to captured requests with your mock definition on the network level. This way your application knows nothing about the mocking. +In-browser usage is what sets Mock Service Worker apart from other tools. Utilizing the [Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API), which can intercept requests for the purpose of caching, Mock Service Worker responds to intercepted requests with your mock definition on the network level. This way your application knows nothing about the mocking. **Take a look at this quick presentation on how Mock Service Worker functions in a browser:** @@ -101,7 +111,7 @@ Performing a `GET https://github.com/octocat` request in your application will r ## Node.js -- [Learn more about using MSW in Node.js](https://mswjs.io/docs/getting-started/integrate/node) +- [Learn more about using MSW in Node.js](https://mswjs.io/docs/integrations/node) - [`setupServer` API](https://mswjs.io/docs/api/setup-server) ### How does it work? @@ -175,7 +185,7 @@ it('displays the list of recent posts', async () => { }) ``` -> Don't get overwhelmed! We've prepared a step-by-step [**Getting started**](https://mswjs.io/docs/getting-started/install) tutorial that you can follow to learn how to integrate Mock Service Worker into your project. +> Don't get overwhelmed! We've prepared a step-by-step [**Getting started**](https://mswjs.io/docs/getting-started) tutorial that you can follow to learn how to integrate Mock Service Worker into your project. Despite the API being called `setupServer`, there are no actual servers involved! The name was chosen for familiarity, and the API was designed to resemble operating with an actual server. @@ -203,6 +213,11 @@ Mock Service Worker is trusted by hundreds of thousands of engineers around the + + + Codacy + + diff --git a/media/egghead-banner.png b/media/egghead-banner.png new file mode 100644 index 000000000..35584bc4b Binary files /dev/null and b/media/egghead-banner.png differ diff --git a/media/sponsors/codacy.svg b/media/sponsors/codacy.svg new file mode 100644 index 000000000..5ad5f930c --- /dev/null +++ b/media/sponsors/codacy.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/media/sponsors/github.svg b/media/sponsors/github.svg index a8d117404..dde07a89a 100644 --- a/media/sponsors/github.svg +++ b/media/sponsors/github.svg @@ -1,3 +1,8 @@ + diff --git a/package.json b/package.json index 8ccf285e5..10be4eb6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "msw", - "version": "0.0.0-fetch.rc-19", + "version": "2.0.0", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", "main": "./lib/core/index.js", "module": "./lib/core/index.mjs", @@ -123,8 +123,8 @@ "chalk": "^4.1.2", "chokidar": "^3.4.2", "formdata-node": "4.4.1", - "graphql": "^15.0.0 || ^16.7.0", - "headers-polyfill": "^3.2.3", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.1", "inquirer": "^8.2.0", "is-node-process": "^1.2.0", "js-levenshtein": "^1.1.6", @@ -143,7 +143,7 @@ "@commitlint/cli": "^16.1.0", "@commitlint/config-conventional": "^16.0.0", "@open-draft/test-server": "^0.4.2", - "@ossjs/release": "^0.7.2", + "@ossjs/release": "^0.8.0", "@playwright/test": "^1.30.0", "@swc/core": "^1.3.35", "@swc/jest": "^0.2.24", @@ -192,7 +192,7 @@ "webpack-http-server": "^0.5.0" }, "peerDependencies": { - "typescript": ">= 4.4.x <= 5.2.x" + "typescript": ">= 4.7.x <= 5.2.x" }, "peerDependenciesMeta": { "typescript": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f2002c50..1adfffe74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ specifiers: '@open-draft/deferred-promise': ^2.1.0 '@open-draft/test-server': ^0.4.2 '@open-draft/until': ^2.1.0 - '@ossjs/release': ^0.7.2 + '@ossjs/release': ^0.8.0 '@playwright/test': ^1.30.0 '@swc/core': ^1.3.35 '@swc/jest': ^0.2.24 @@ -52,8 +52,8 @@ specifiers: fs-extra: ^10.0.0 fs-teardown: ^0.3.0 glob: ^9.3.4 - graphql: ^15.0.0 || ^16.7.0 - headers-polyfill: ^3.2.3 + graphql: ^16.8.1 + headers-polyfill: ^4.0.1 inquirer: ^8.2.0 is-node-process: ^1.2.0 jest: ^29.4.3 @@ -97,8 +97,8 @@ dependencies: chalk: 4.1.2 chokidar: 3.4.1 formdata-node: 4.4.1 - graphql: 16.8.0 - headers-polyfill: 3.2.3 + graphql: 16.8.1 + headers-polyfill: 4.0.1 inquirer: 8.2.5 is-node-process: 1.2.0 js-levenshtein: 1.1.6 @@ -117,7 +117,7 @@ devDependencies: '@commitlint/cli': 16.3.0_@swc+core@1.3.35 '@commitlint/config-conventional': 16.2.4 '@open-draft/test-server': 0.4.2 - '@ossjs/release': 0.7.2 + '@ossjs/release': 0.8.0 '@playwright/test': 1.30.0 '@swc/core': 1.3.35 '@swc/jest': 0.2.24_@swc+core@1.3.35 @@ -2415,8 +2415,8 @@ packages: /@open-draft/until/2.1.0: resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - /@ossjs/release/0.7.2: - resolution: {integrity: sha512-s8w7VRC6Xf1vfpIsDG2CflWinSg9O7H/6nckxaBCiMWClkeaZ3JuXzZEIcybc6jeAFc1Rz7UilCvgZflb06Y5g==} + /@ossjs/release/0.8.0: + resolution: {integrity: sha512-vzxhYvad/Ub3j8bWWCRfdwTvFzK3HtKjm8IM5J+7njnQcZZie5iouUXX+G65OI3F1YgQSWvsozrWqHyN1x7fjQ==} hasBin: true dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -2429,7 +2429,7 @@ packages: '@types/registry-auth-token': 4.2.1 '@types/semver': 7.5.1 '@types/yargs': 17.0.22 - conventional-commits-parser: 3.2.4 + conventional-commits-parser: 5.0.0 get-stream: 6.0.1 git-log-parser: 1.2.0 issue-parser: 6.0.0 @@ -4601,6 +4601,17 @@ packages: through2: 4.0.2 dev: true + /conventional-commits-parser/5.0.0: + resolution: {integrity: sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==} + engines: {node: '>=16'} + hasBin: true + dependencies: + JSONStream: 1.3.5 + is-text-path: 2.0.0 + meow: 12.1.1 + split2: 4.1.0 + dev: true + /convert-source-map/1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} dev: true @@ -5160,8 +5171,8 @@ packages: resolution: {integrity: sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==} engines: {node: '>=10.0.0'} - /engine.io/6.5.2: - resolution: {integrity: sha512-IXsMcGpw/xRfjra46sVZVHiSWo/nJ/3g1337q9KNXtS6YRzbW5yIzTCb9DjhrBe7r3GZQR0I4+nq+4ODk5g/cA==} + /engine.io/6.5.3: + resolution: {integrity: sha512-IML/R4eG/pUS5w7OfcDE0jKrljWS9nwnEfsxWCIJF5eO6AHo6+Hlv+lQbdlAYsiJPHzUthLm1RUjnBzWOs45cw==} engines: {node: '>=10.2.0'} dependencies: '@types/cookie': 0.4.1 @@ -6293,8 +6304,8 @@ packages: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} dev: true - /graphql/16.8.0: - resolution: {integrity: sha512-0oKGaR+y3qcS5mCu1vb7KG+a89vjn06C7Ihq/dDl3jA+A8B3TKomvi3CiEcVLJQGalbu8F52LxkOym7U5sSfbg==} + /graphql/16.8.1: + resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} dev: false @@ -6383,6 +6394,11 @@ packages: /headers-polyfill/3.2.3: resolution: {integrity: sha512-oj6MO8sdFQ9gQQedSVdMGh96suxTNp91vPQu7C4qx/57FqYsA5TiNr92nhIZwVQq8zygn4nu3xS1aEqpakGqdw==} + dev: true + + /headers-polyfill/4.0.1: + resolution: {integrity: sha512-CD3yq1U/nwyKZHRFIjESyveXz6Buk0ImoIwlEOEyNVNAqJLjNX3YkJkaH9Mg5rqU5JiVgTBq/6Z0jR1L6KS0Gg==} + dev: false /homedir-polyfill/1.0.3: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} @@ -6959,6 +6975,13 @@ packages: text-extensions: 1.9.0 dev: true + /is-text-path/2.0.0: + resolution: {integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==} + engines: {node: '>=8'} + dependencies: + text-extensions: 2.4.0 + dev: true + /is-typed-array/1.1.10: resolution: {integrity: sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==} engines: {node: '>= 0.4'} @@ -8044,6 +8067,11 @@ packages: readable-stream: 2.3.7 dev: true + /meow/12.1.1: + resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} + engines: {node: '>=16.10'} + dev: true + /meow/8.1.2: resolution: {integrity: sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==} engines: {node: '>=10'} @@ -9728,7 +9756,7 @@ packages: base64id: 2.0.0 cors: 2.8.5 debug: 4.3.4 - engine.io: 6.5.2 + engine.io: 6.5.3 socket.io-adapter: 2.5.2 socket.io-parser: 4.2.4 transitivePeerDependencies: @@ -10203,6 +10231,11 @@ packages: engines: {node: '>=0.10'} dev: true + /text-extensions/2.4.0: + resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} + engines: {node: '>=8'} + dev: true + /text-table/0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true diff --git a/src/browser/setupWorker/glossary.ts b/src/browser/setupWorker/glossary.ts index cf301f274..23017c6ec 100644 --- a/src/browser/setupWorker/glossary.ts +++ b/src/browser/setupWorker/glossary.ts @@ -35,7 +35,7 @@ type RequestWithoutMethods = Omit< export interface ServiceWorkerIncomingRequest extends RequestWithoutMethods { /** * Unique ID of the request generated once the request is - * captured by the "fetch" event in the Service Worker. + * intercepted by the "fetch" event in the Service Worker. */ id: string body?: ArrayBuffer | null @@ -72,7 +72,7 @@ export type ServiceWorkerOutgoingEventTypes = | 'CLIENT_CLOSED' export interface StringifiedResponse extends ResponseInit { - body: string | ReadableStream | null + body: string | ArrayBuffer | ReadableStream | null } /** @@ -146,7 +146,10 @@ export interface SetupWorkerInternalContext { ServiceWorkerMessage > } - useFallbackMode: boolean + supports: { + serviceWorkerApi: boolean + readableStreamTransfer: boolean + } fallbackInterceptor?: Interceptor } @@ -174,7 +177,7 @@ export interface StartOptions extends SharedOptions { } /** - * Disables the logging of captured requests + * Disables the logging of the intercepted requests * into browser's console. * @default false */ @@ -204,41 +207,53 @@ export type StopHandler = () => void export interface SetupWorker { /** * Registers and activates the mock Service Worker. - * @see {@link https://mswjs.io/docs/api/setup-worker/start `worker.start()`} + * + * @see {@link https://mswjs.io/docs/api/setup-worker/start `worker.start()` API reference} */ start: (options?: StartOptions) => StartReturnType /** * Stops requests interception for the current client. - * @see {@link https://mswjs.io/docs/api/setup-worker/stop `worker.stop()`} + * + * @see {@link https://mswjs.io/docs/api/setup-worker/stop `worker.stop()` API reference} */ stop: StopHandler /** * Prepends given request handlers to the list of existing handlers. * @param {RequestHandler[]} handlers List of runtime request handlers. - * @see {@link https://mswjs.io/docs/api/setup-worker/use `worker.use()`} + * + * @see {@link https://mswjs.io/docs/api/setup-worker/use `worker.use()` API reference} */ use: (...handlers: RequestHandler[]) => void /** * Marks all request handlers that respond using `res.once()` as unused. - * @see {@link https://mswjs.io/docs/api/setup-worker/restore-handlers `worker.restoreHandlers()`} + * + * @see {@link https://mswjs.io/docs/api/setup-worker/restore-handlers `worker.restoreHandlers()` API reference} */ restoreHandlers: () => void /** * Resets request handlers to the initial list given to the `setupWorker` call, or to the explicit next request handlers list, if given. * @param {RequestHandler[]} nextHandlers List of the new initial request handlers. - * @see {@link https://mswjs.io/docs/api/setup-worker/reset-handlers `worker.resetHandlers()`} + * + * @see {@link https://mswjs.io/docs/api/setup-worker/reset-handlers `worker.resetHandlers()` API reference} */ resetHandlers: (...nextHandlers: RequestHandler[]) => void /** * Returns a readonly list of currently active request handlers. - * @see {@link https://mswjs.io/docs/api/setup-worker/list-handlers `worker.listHandlers()`} + * + * @see {@link https://mswjs.io/docs/api/setup-worker/list-handlers `worker.listHandlers()` API reference} */ listHandlers(): ReadonlyArray> + /** + * Life-cycle events. + * Life-cycle events allow you to subscribe to the internal library events occurring during the request/response handling. + * + * @see {@link https://mswjs.io/docs/api/life-cycle-events Life-cycle Events API reference} + */ events: LifeCycleEventEmitter } diff --git a/src/browser/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts index d85169d64..b89376641 100644 --- a/src/browser/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -20,6 +20,7 @@ import { SetupApi } from '~/core/SetupApi' import { mergeRight } from '~/core/utils/internal/mergeRight' import { LifeCycleEventsMap } from '~/core/sharedOptions' import { SetupWorker } from './glossary' +import { supportsReadableStreamTransfer } from '../utils/supportsReadableStreamTransfer' interface Listener { target: EventTarget @@ -142,8 +143,11 @@ export class SetupWorkerApi }) }, }, - useFallbackMode: - !('serviceWorker' in navigator) || location.protocol === 'file:', + supports: { + serviceWorkerApi: + !('serviceWorker' in navigator) || location.protocol === 'file:', + readableStreamTransfer: supportsReadableStreamTransfer(), + }, } /** @@ -156,11 +160,11 @@ export class SetupWorkerApi }, }) - this.startHandler = context.useFallbackMode + this.startHandler = context.supports.serviceWorkerApi ? createFallbackStart(context) : createStartHandler(context) - this.stopHandler = context.useFallbackMode + this.stopHandler = context.supports.serviceWorkerApi ? createFallbackStop(context) : createStop(context) @@ -187,7 +191,8 @@ export class SetupWorkerApi /** * Sets up a requests interception in the browser with the given request handlers. * @param {RequestHandler[]} handlers List of request handlers. - * @see {@link https://mswjs.io/docs/api/setup-worker `setupWorker`} + * + * @see {@link https://mswjs.io/docs/api/setup-worker `setupWorker()` API reference} */ export function setupWorker(...handlers: Array): SetupWorker { return new SetupWorkerApi(...handlers) diff --git a/src/browser/setupWorker/start/createFallbackRequestListener.ts b/src/browser/setupWorker/start/createFallbackRequestListener.ts index 93564371b..c722b2772 100644 --- a/src/browser/setupWorker/start/createFallbackRequestListener.ts +++ b/src/browser/setupWorker/start/createFallbackRequestListener.ts @@ -28,10 +28,14 @@ export function createFallbackRequestListener( options, context.emitter, { - onMockedResponse(_, { handler, parsedRequest }) { + onMockedResponse(_, { handler, parsedResult }) { if (!options.quiet) { context.emitter.once('response:mocked', ({ response }) => { - handler.log(requestCloneForLogs, response, parsedRequest) + handler.log({ + request: requestCloneForLogs, + response, + parsedResult, + }) }) } }, diff --git a/src/browser/setupWorker/start/createRequestListener.ts b/src/browser/setupWorker/start/createRequestListener.ts index 1fa973045..b6ee1e56a 100644 --- a/src/browser/setupWorker/start/createRequestListener.ts +++ b/src/browser/setupWorker/start/createRequestListener.ts @@ -41,28 +41,43 @@ export const createRequestListener = ( onPassthroughResponse() { messageChannel.postMessage('NOT_FOUND') }, - async onMockedResponse(response, { handler, parsedRequest }) { + async onMockedResponse(response, { handler, parsedResult }) { // Clone the mocked response so its body could be read // to buffer to be sent to the worker and also in the // ".log()" method of the request handler. const responseClone = response.clone() const responseInit = toResponseInit(response) - const responseStream = responseClone.body - messageChannel.postMessage( - 'MOCK_RESPONSE', - { + /** + * @note Safari doesn't support transferring a "ReadableStream". + * Check that the browser supports that before sending it to the worker. + */ + if (context.supports.readableStreamTransfer) { + const responseStream = response.body + messageChannel.postMessage( + 'MOCK_RESPONSE', + { + ...responseInit, + body: responseStream, + }, + responseStream ? [responseStream] : undefined, + ) + } else { + // As a fallback, send the response body buffer to the worker. + const responseBuffer = await responseClone.arrayBuffer() + messageChannel.postMessage('MOCK_RESPONSE', { ...responseInit, - body: responseStream, - }, - // Transfer response's buffer so it could - // be sent over to the worker. - responseStream ? [responseStream] : undefined, - ) + body: responseBuffer, + }) + } if (!options.quiet) { context.emitter.once('response:mocked', ({ response }) => { - handler.log(requestCloneForLogs, response, parsedRequest) + handler.log({ + request: requestCloneForLogs, + response, + parsedResult, + }) }) } }, diff --git a/src/browser/tsconfig.json b/src/browser/tsconfig.json new file mode 100644 index 000000000..30d12be0c --- /dev/null +++ b/src/browser/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["dom", "WebWorker"] + }, + "include": ["./**/*.ts"] +} diff --git a/src/browser/utils/deferNetworkRequestsUntil.test.ts b/src/browser/utils/deferNetworkRequestsUntil.test.ts index 8ba23be8a..c5f0e963c 100644 --- a/src/browser/utils/deferNetworkRequestsUntil.test.ts +++ b/src/browser/utils/deferNetworkRequestsUntil.test.ts @@ -31,7 +31,7 @@ test('defers any requests that happen while a given promise is pending', async ( events.push('promise resolved') }) - // Calling this functions captures all requests that happen while + // Calling this functions intercepts all requests that happen while // the given promise is pending, and defers their execution // until the promise is resolved. deferNetworkRequestsUntil(workerPromise) diff --git a/src/browser/utils/supportsReadableStreamTransfer.ts b/src/browser/utils/supportsReadableStreamTransfer.ts new file mode 100644 index 000000000..b1c5dc295 --- /dev/null +++ b/src/browser/utils/supportsReadableStreamTransfer.ts @@ -0,0 +1,17 @@ +/** + * Returns a boolean indicating whether the current browser + * supports `ReadableStream` as a `Transferable` when posting + * messages. + */ +export function supportsReadableStreamTransfer() { + try { + const stream = new ReadableStream({ + start: (controller) => controller.close(), + }) + const message = new MessageChannel() + message.port1.postMessage(stream, [stream]) + return true + } catch (error) { + return false + } +} diff --git a/src/core/HttpResponse.ts b/src/core/HttpResponse.ts index 8342d66be..e5e51c482 100644 --- a/src/core/HttpResponse.ts +++ b/src/core/HttpResponse.ts @@ -24,9 +24,15 @@ export interface StrictResponse } /** - * A `Response` class superset with a stricter response body type. + * A drop-in replacement for the standard `Response` class + * to allow additional features, like mocking the response `Set-Cookie` header. + * * @example * new HttpResponse('Hello world', { status: 201 }) + * HttpResponse.json({ name: 'John' }) + * HttpResponse.formData(form) + * + * @see {@link https://mswjs.io/docs/api/http-response `HttpResponse` API reference} */ export class HttpResponse extends Response { constructor(body?: BodyInit | null, init?: HttpResponseInit) { diff --git a/src/core/bypass.test.ts b/src/core/bypass.test.ts index a4de4d595..aca432070 100644 --- a/src/core/bypass.test.ts +++ b/src/core/bypass.test.ts @@ -1,24 +1,26 @@ /** * @jest-environment jsdom */ -import { Headers } from 'headers-polyfill' import { bypass } from './bypass' it('returns bypassed request given a request url string', async () => { - const [url, init] = await bypass('/user') - const headers = new Headers(init.headers) + const request = bypass('https://api.example.com/resource') // Relative URLs are rebased against the current location. - expect(url).toBe('/user') - expect(headers.get('x-msw-intention')).toBe('bypass') + expect(request.method).toBe('GET') + expect(request.url).toBe('https://api.example.com/resource') + expect(Object.fromEntries(request.headers.entries())).toEqual({ + 'x-msw-intention': 'bypass', + }) }) it('returns bypassed request given a request url', async () => { - const [url, init] = await bypass(new URL('/user', 'https://api.github.com')) - const headers = new Headers(init.headers) + const request = bypass(new URL('/resource', 'https://api.example.com')) - expect(url).toBe('https://api.github.com/user') - expect(headers.get('x-msw-intention')).toBe('bypass') + expect(request.url).toBe('https://api.example.com/resource') + expect(Object.fromEntries(request.headers)).toEqual({ + 'x-msw-intention': 'bypass', + }) }) it('returns bypassed request given request instance', async () => { @@ -29,12 +31,17 @@ it('returns bypassed request given request instance', async () => { }, body: 'hello world', }) - const [url, init] = await bypass(original) - const headers = new Headers(init.headers) - - expect(url).toBe('http://localhost/resource') - expect(init.method).toBe('POST') - expect(init.body).toEqual(await original.arrayBuffer()) - expect(headers.get('x-msw-intention')).toBe('bypass') - expect(headers.get('x-my-header')).toBe('value') + const request = bypass(original) + + expect(request.method).toBe('POST') + expect(request.url).toBe('http://localhost/resource') + + const bypassedRequestBody = await request.text() + expect(original.bodyUsed).toBe(false) + + expect(bypassedRequestBody).toEqual(await original.text()) + expect(Object.fromEntries(request.headers.entries())).toEqual({ + ...Object.fromEntries(original.headers.entries()), + 'x-msw-intention': 'bypass', + }) }) diff --git a/src/core/bypass.ts b/src/core/bypass.ts index 3e37866d3..e467cf30c 100644 --- a/src/core/bypass.ts +++ b/src/core/bypass.ts @@ -1,92 +1,36 @@ import { invariant } from 'outvariant' -import { Headers } from 'headers-polyfill' export type BypassRequestInput = string | URL | Request /** - * Derives request input and init from the given Request info - * to define a request that will always be ignored by MSW. + * Creates a `Request` instance that will always be ignored by MSW. * * @example - * import fetch, { Request } from 'node-fetch' * import { bypass } from 'msw' * - * fetch(...bypass('/resource')) - * fetch(...bypass(new URL('/resource', 'https://example.com))) - * fetch(...bypass(new Request('https://example.com/resource'))) + * fetch(bypass('/resource')) + * fetch(bypass(new URL('/resource', 'https://example.com))) + * fetch(bypass(new Request('https://example.com/resource'))) + * + * @see {@link https://mswjs.io/docs/api/bypass `bypass()` API reference} */ -export async function bypass( - input: BypassRequestInput, - init?: RequestInit, -): Promise<[string, RequestInit]> { - if (isRequest(input)) { - invariant( - !input.bodyUsed, - 'Failed to create a bypassed request to "%s %s": given request instance already has its body read. Make sure to clone the intercepted request if you wish to read its body before bypassing it.', - input.method, - input.url, - ) - } +export function bypass(input: BypassRequestInput, init?: RequestInit): Request { + const request = input instanceof Request ? input : new Request(input, init) + + invariant( + !request.bodyUsed, + 'Failed to create a bypassed request to "%s %s": given request instance already has its body read. Make sure to clone the intercepted request if you wish to read its body before bypassing it.', + request.method, + request.url, + ) - const url = isRequest(input) ? input.url : input.toString() - const resolvedInit: RequestInit = - typeof init !== 'undefined' ? init : await getRequestInit(input) + const requestClone = request.clone() // Set the internal header that would instruct MSW // to bypass this request from any further request matching. // Unlike "passthrough()", bypass is meant for performing // additional requests within pending request resolution. - const headers = new Headers(resolvedInit.headers) - headers.set('x-msw-intention', 'bypass') - resolvedInit.headers = headers - - return [url, resolvedInit] -} - -function isRequest(input: BypassRequestInput): input is Request { - return ( - typeof input === 'object' && - input.constructor.name === 'Request' && - 'clone' in input && - typeof input.clone === 'function' - ) -} - -async function getRequestInit(input: BypassRequestInput): Promise { - if (!isRequest(input)) { - return {} - } - - const init: RequestInit = { - // Set each request init property explicitly - // to prevent leaking internal properties of whichever - // Request polyfill provided as the input. - mode: input.mode, - method: input.method, - cache: input.cache, - headers: input.headers, - credentials: input.credentials, - signal: input.signal, - referrerPolicy: input.referrerPolicy, - referrer: input.referrer, - redirect: input.redirect, - integrity: input.integrity, - keepalive: input.keepalive, - } - - // Include "RequestInit.body" only for appropriate requests. - if (init.method !== 'HEAD' && input.method !== 'GET') { - init.body = await input.clone().arrayBuffer() - - /** - * `RequestInit.duplex` is not present in TypeScript but is - * required if you wish to send `ReadableStream` as a request body. - * @see https://developer.chrome.com/articles/fetch-streaming-requests - * @see https://github.com/whatwg/fetch/pull/1457 - */ - // @ts-ignore - init.duplex = input.duplex - } + requestClone.headers.set('x-msw-intention', 'bypass') - return init + return requestClone } diff --git a/src/core/delay.ts b/src/core/delay.ts index 9bbe68bf8..0244567ed 100644 --- a/src/core/delay.ts +++ b/src/core/delay.ts @@ -20,10 +20,13 @@ export type DelayMode = 'real' | 'infinite' /** * Delays the response by the given duration (ms). + * * @example * await delay() // emulate realistic server response time * await delay(1200) // delay response by 1200ms * await delay('infinite') // delay response infinitely + * + * @see {@link https://mswjs.io/docs/api/delay `delay()` API reference} */ export async function delay( durationOrMode?: DelayMode | number, diff --git a/src/core/graphql.test.ts b/src/core/graphql.test.ts index 0658b09a5..e783d0fbd 100644 --- a/src/core/graphql.test.ts +++ b/src/core/graphql.test.ts @@ -3,9 +3,9 @@ import { graphql } from './graphql' test('exports supported GraphQL operation types', () => { expect(graphql).toBeDefined() expect(Object.keys(graphql)).toEqual([ - 'operation', 'query', 'mutation', + 'operation', 'link', ]) }) diff --git a/src/core/graphql.ts b/src/core/graphql.ts index c9ae77070..b3409eb7f 100644 --- a/src/core/graphql.ts +++ b/src/core/graphql.ts @@ -68,34 +68,41 @@ function createGraphQLOperationHandler(url: Path) { const standardGraphQLHandlers = { /** - * Captures any GraphQL operation, regardless of its name, under the current scope. - * @example - * graphql.operation(() => { - * return HttpResponse.json({ data: { name: 'John' } }) - * }) - * @see {@link https://mswjs.io/docs/api/graphql/operation `graphql.operation()`} - */ - operation: createGraphQLOperationHandler('*'), - - /** - * Captures a GraphQL query by a given name. + * Intercepts a GraphQL query by a given name. + * * @example * graphql.query('GetUser', () => { * return HttpResponse.json({ data: { user: { name: 'John' } } }) * }) - * @see {@link https://mswjs.io/docs/api/graphql/query `graphql.query()`} + * + * @see {@link https://mswjs.io/docs/api/graphql#graphqlqueryqueryname-resolver `graphql.query()` API reference} */ query: createScopedGraphQLHandler('query' as OperationTypeNode, '*'), /** - * Captures a GraphQL mutation by a given name. + * Intercepts a GraphQL mutation by its name. + * * @example * graphql.mutation('SavePost', () => { * return HttpResponse.json({ data: { post: { id: 'abc-123 } } }) * }) - * @see {@link https://mswjs.io/docs/api/graphql/mutation `graphql.mutation()`} + * + * @see {@link https://mswjs.io/docs/api/graphql#graphqlmutationmutationname-resolver `graphql.query()` API reference} + * */ mutation: createScopedGraphQLHandler('mutation' as OperationTypeNode, '*'), + + /** + * Intercepts any GraphQL operation, regardless of its type or name. + * + * @example + * graphql.operation(() => { + * return HttpResponse.json({ data: { name: 'John' } }) + * }) + * + * @see {@link https://mswjs.io/docs/api/graphql#graphloperationresolver `graphql.operation()` API reference} + */ + operation: createGraphQLOperationHandler('*'), } function createGraphQLLink(url: Path): typeof standardGraphQLHandlers { @@ -106,7 +113,26 @@ function createGraphQLLink(url: Path): typeof standardGraphQLHandlers { } } +/** + * A namespace to intercept and mock GraphQL operations + * + * @example + * graphql.query('GetUser', resolver) + * graphql.mutation('DeletePost', resolver) + * + * @see {@link https://mswjs.io/docs/api/graphql `graphql` API reference} + */ export const graphql = { ...standardGraphQLHandlers, + + /** + * Intercepts GraphQL operations scoped by the given URL. + * + * @example + * const github = graphql.link('https://api.github.com/graphql') + * github.query('GetRepo', resolver) + * + * @see {@link https://mswjs.io/docs/api/graphql#graphqllinkurl `graphql.link()` API reference} + */ link: createGraphQLLink, } diff --git a/src/core/handlers/GraphQLHandler.test.ts b/src/core/handlers/GraphQLHandler.test.ts index f81df17c2..cebac6440 100644 --- a/src/core/handlers/GraphQLHandler.test.ts +++ b/src/core/handlers/GraphQLHandler.test.ts @@ -3,7 +3,6 @@ */ import { encodeBuffer } from '@mswjs/interceptors' import { OperationTypeNode, parse } from 'graphql' -import { Headers } from 'headers-polyfill' import { GraphQLHandler, GraphQLRequestBody, @@ -160,7 +159,7 @@ describe('parse', () => { query: GET_USER, }) - expect(await handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'query', operationName: 'GetUser', query: GET_USER, @@ -182,7 +181,7 @@ describe('parse', () => { }, }) - expect(await handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'query', operationName: 'GetUser', query: GET_USER, @@ -203,7 +202,7 @@ describe('parse', () => { query: GET_USER, }) - expect(await handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'query', operationName: 'GetUser', query: GET_USER, @@ -225,7 +224,7 @@ describe('parse', () => { }, }) - expect(await handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'query', operationName: 'GetUser', query: GET_USER, @@ -248,7 +247,7 @@ describe('parse', () => { query: LOGIN, }) - expect(await handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'mutation', operationName: 'Login', query: LOGIN, @@ -270,7 +269,7 @@ describe('parse', () => { }, }) - expect(await handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'mutation', operationName: 'Login', query: LOGIN, @@ -291,7 +290,7 @@ describe('parse', () => { query: LOGIN, }) - expect(await handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'mutation', operationName: 'Login', query: LOGIN, @@ -313,7 +312,7 @@ describe('parse', () => { }, }) - expect(await handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'mutation', operationName: 'Login', query: LOGIN, @@ -340,9 +339,17 @@ describe('predicate', () => { query: LOGIN, }) - expect(handler.predicate(request, await handler.parse(request))).toBe(true) expect( - handler.predicate(alienRequest, await handler.parse(alienRequest)), + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) + expect( + handler.predicate({ + request: alienRequest, + parsedResult: await handler.parse({ request: alienRequest }), + }), ).toBe(false) }) @@ -366,9 +373,17 @@ describe('predicate', () => { `, }) - expect(handler.predicate(request, await handler.parse(request))).toBe(true) expect( - handler.predicate(alienRequest, await handler.parse(alienRequest)), + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) + expect( + handler.predicate({ + request: alienRequest, + parsedResult: await handler.parse({ request: alienRequest }), + }), ).toBe(false) }) @@ -385,7 +400,12 @@ describe('predicate', () => { `, }) - expect(handler.predicate(request, await handler.parse(request))).toBe(true) + expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) }) test('respects custom endpoint', async () => { @@ -405,9 +425,17 @@ describe('predicate', () => { query: GET_USER, }) - expect(handler.predicate(request, await handler.parse(request))).toBe(true) expect( - handler.predicate(alienRequest, await handler.parse(alienRequest)), + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) + expect( + handler.predicate({ + request: alienRequest, + parsedResult: await handler.parse({ request: alienRequest }), + }), ).toBe(false) }) }) @@ -427,8 +455,8 @@ describe('test', () => { query: LOGIN, }) - expect(await handler.test(request)).toBe(true) - expect(await handler.test(alienRequest)).toBe(false) + expect(await handler.test({ request })).toBe(true) + expect(await handler.test({ request: alienRequest })).toBe(false) }) test('respects operation name', async () => { @@ -451,8 +479,8 @@ describe('test', () => { `, }) - expect(await handler.test(request)).toBe(true) - expect(await handler.test(alienRequest)).toBe(false) + expect(await handler.test({ request })).toBe(true) + expect(await handler.test({ request: alienRequest })).toBe(false) }) test('respects custom endpoint', async () => { @@ -472,8 +500,8 @@ describe('test', () => { query: GET_USER, }) - expect(await handler.test(request)).toBe(true) - expect(await handler.test(alienRequest)).toBe(false) + expect(await handler.test({ request })).toBe(true) + expect(await handler.test({ request: alienRequest })).toBe(false) }) }) @@ -491,7 +519,7 @@ describe('run', () => { userId: 'abc-123', }, }) - const result = await handler.run(request) + const result = await handler.run({ request }) expect(result!.handler).toEqual(handler) expect(result!.parsedResult).toEqual({ @@ -525,7 +553,7 @@ describe('run', () => { const request = createPostGraphQLRequest({ query: LOGIN, }) - const result = await handler.run(request) + const result = await handler.run({ request }) expect(result).toBeNull() }) @@ -572,7 +600,7 @@ describe('request', () => { `, }) - await handler.run(request) + await handler.run({ request }) expect(matchAllResolver).toHaveBeenCalledTimes(1) expect(matchAllResolver.mock.calls[0][0]).toHaveProperty( diff --git a/src/core/handlers/GraphQLHandler.ts b/src/core/handlers/GraphQLHandler.ts index 5a471482a..0c3e56e3b 100644 --- a/src/core/handlers/GraphQLHandler.ts +++ b/src/core/handlers/GraphQLHandler.ts @@ -8,8 +8,8 @@ import { } from './RequestHandler' import { getTimestamp } from '../utils/logging/getTimestamp' import { getStatusCodeColor } from '../utils/logging/getStatusCodeColor' -import { requestToLoggableObject } from '../utils/logging/requestToLoggableObject' -import { responseToLoggableObject } from '../utils/logging/responseToLoggableObject' +import { serializeRequest } from '../utils/logging/serializeRequest' +import { serializeResponse } from '../utils/logging/serializeResponse' import { matchRequestUrl, Path } from '../utils/matching/matchRequestUrl' import { ParsedGraphQLRequest, @@ -114,37 +114,40 @@ export class GraphQLHandler extends RequestHandler< this.endpoint = endpoint } - async parse(request: Request) { - return parseGraphQLRequest(request).catch((error) => { + async parse(args: { request: Request }) { + return parseGraphQLRequest(args.request).catch((error) => { console.error(error) return undefined }) } - predicate(request: Request, parsedResult: ParsedGraphQLRequest) { - if (!parsedResult) { + predicate(args: { request: Request; parsedResult: ParsedGraphQLRequest }) { + if (!args.parsedResult) { return false } - if (!parsedResult.operationName && this.info.operationType !== 'all') { - const publicUrl = getPublicUrlFromRequest(request) + if (!args.parsedResult.operationName && this.info.operationType !== 'all') { + const publicUrl = getPublicUrlFromRequest(args.request) devUtils.warn(`\ -Failed to intercept a GraphQL request at "${request.method} ${publicUrl}": anonymous GraphQL operations are not supported. +Failed to intercept a GraphQL request at "${args.request.method} ${publicUrl}": anonymous GraphQL operations are not supported. Consider naming this operation or using "graphql.operation()" request handler to intercept GraphQL requests regardless of their operation name/type. Read more: https://mswjs.io/docs/api/graphql/operation`) return false } - const hasMatchingUrl = matchRequestUrl(new URL(request.url), this.endpoint) + const hasMatchingUrl = matchRequestUrl( + new URL(args.request.url), + this.endpoint, + ) const hasMatchingOperationType = this.info.operationType === 'all' || - parsedResult.operationType === this.info.operationType + args.parsedResult.operationType === this.info.operationType const hasMatchingOperationName = this.info.operationName instanceof RegExp - ? this.info.operationName.test(parsedResult.operationName || '') - : parsedResult.operationName === this.info.operationName + ? this.info.operationName.test(args.parsedResult.operationName || '') + : args.parsedResult.operationName === this.info.operationName return ( hasMatchingUrl.matches && @@ -153,28 +156,28 @@ Consider naming this operation or using "graphql.operation()" request handler to ) } - protected extendInfo( - _request: Request, - parsedResult: ParsedGraphQLRequest, - ) { + protected extendResolverArgs(args: { + request: Request + parsedResult: ParsedGraphQLRequest + }) { return { - query: parsedResult?.query || '', - operationName: parsedResult?.operationName || '', - variables: parsedResult?.variables || {}, + query: args.parsedResult?.query || '', + operationName: args.parsedResult?.operationName || '', + variables: args.parsedResult?.variables || {}, } } - async log( - request: Request, - response: Response, - parsedRequest: ParsedGraphQLRequest, - ) { - const loggedRequest = await requestToLoggableObject(request) - const loggedResponse = await responseToLoggableObject(response) + async log(args: { + request: Request + response: Response + parsedResult: ParsedGraphQLRequest + }) { + const loggedRequest = await serializeRequest(args.request) + const loggedResponse = await serializeResponse(args.response) const statusColor = getStatusCodeColor(loggedResponse.status) - const requestInfo = parsedRequest?.operationName - ? `${parsedRequest?.operationType} ${parsedRequest?.operationName}` - : `anonymous ${parsedRequest?.operationType}` + const requestInfo = args.parsedResult?.operationName + ? `${args.parsedResult?.operationType} ${args.parsedResult?.operationName}` + : `anonymous ${args.parsedResult?.operationType}` console.groupCollapsed( devUtils.formatMessage('%s %s (%c%s%c)'), diff --git a/src/core/handlers/HttpHandler.test.ts b/src/core/handlers/HttpHandler.test.ts index 2ba461c52..0d0ac2d49 100644 --- a/src/core/handlers/HttpHandler.test.ts +++ b/src/core/handlers/HttpHandler.test.ts @@ -27,7 +27,7 @@ describe('parse', () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) const request = new Request(new URL('/user/abc-123', location.href)) - expect(await handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ match: { matches: true, params: { @@ -44,7 +44,7 @@ describe('parse', () => { method: 'POST', }) - expect(await handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ match: { matches: true, params: { @@ -59,7 +59,7 @@ describe('parse', () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) const request = new Request(new URL('/login', location.href)) - expect(await handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ match: { matches: false, params: {}, @@ -76,7 +76,12 @@ describe('predicate', () => { method: 'POST', }) - expect(handler.predicate(request, await handler.parse(request))).toBe(true) + expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) }) test('respects RegExp as the request method', async () => { @@ -88,9 +93,12 @@ describe('predicate', () => { ] for (const request of requests) { - expect(handler.predicate(request, await handler.parse(request))).toBe( - true, - ) + expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) } }) @@ -98,19 +106,24 @@ describe('predicate', () => { const handler = new HttpHandler('POST', '/login', resolver) const request = new Request(new URL('/user/abc-123', location.href)) - expect(handler.predicate(request, await handler.parse(request))).toBe(false) + expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(false) }) }) describe('test', () => { test('returns true given a matching request', async () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) - const firstTest = await handler.test( - new Request(new URL('/user/abc-123', location.href)), - ) - const secondTest = await handler.test( - new Request(new URL('/user/def-456', location.href)), - ) + const firstTest = await handler.test({ + request: new Request(new URL('/user/abc-123', location.href)), + }) + const secondTest = await handler.test({ + request: new Request(new URL('/user/def-456', location.href)), + }) expect(firstTest).toBe(true) expect(secondTest).toBe(true) @@ -118,15 +131,15 @@ describe('test', () => { test('returns false given a non-matching request', async () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) - const firstTest = await handler.test( - new Request(new URL('/login', location.href)), - ) - const secondTest = await handler.test( - new Request(new URL('/user/', location.href)), - ) - const thirdTest = await handler.test( - new Request(new URL('/user/abc-123/extra', location.href)), - ) + const firstTest = await handler.test({ + request: new Request(new URL('/login', location.href)), + }) + const secondTest = await handler.test({ + request: new Request(new URL('/user/', location.href)), + }) + const thirdTest = await handler.test({ + request: new Request(new URL('/user/abc-123/extra', location.href)), + }) expect(firstTest).toBe(false) expect(secondTest).toBe(false) @@ -138,7 +151,7 @@ describe('run', () => { test('returns a mocked response given a matching request', async () => { const handler = new HttpHandler('GET', '/user/:userId', resolver) const request = new Request(new URL('/user/abc-123', location.href)) - const result = await handler.run(request) + const result = await handler.run({ request }) expect(result!.handler).toEqual(handler) expect(result!.parsedResult).toEqual({ @@ -159,18 +172,18 @@ describe('run', () => { test('returns null given a non-matching request', async () => { const handler = new HttpHandler('POST', '/login', resolver) - const result = await handler.run( - new Request(new URL('/users', location.href)), - ) + const result = await handler.run({ + request: new Request(new URL('/users', location.href)), + }) expect(result).toBeNull() }) test('returns an empty "params" object given request with no URL parameters', async () => { const handler = new HttpHandler('GET', '/users', resolver) - const result = await handler.run( - new Request(new URL('/users', location.href)), - ) + const result = await handler.run({ + request: new Request(new URL('/users', location.href)), + }) expect(result?.parsedResult?.match?.params).toEqual({}) }) @@ -188,9 +201,9 @@ describe('run', () => { }) const run = async () => { - const result = await handler.run( - new Request(new URL('/users', location.href)), - ) + const result = await handler.run({ + request: new Request(new URL('/users', location.href)), + }) return result?.response?.text() } diff --git a/src/core/handlers/HttpHandler.ts b/src/core/handlers/HttpHandler.ts index 37172e7a0..963814785 100644 --- a/src/core/handlers/HttpHandler.ts +++ b/src/core/handlers/HttpHandler.ts @@ -3,8 +3,8 @@ import { devUtils } from '../utils/internal/devUtils' import { isStringEqual } from '../utils/internal/isStringEqual' import { getStatusCodeColor } from '../utils/logging/getStatusCodeColor' import { getTimestamp } from '../utils/logging/getTimestamp' -import { requestToLoggableObject } from '../utils/logging/requestToLoggableObject' -import { responseToLoggableObject } from '../utils/logging/responseToLoggableObject' +import { serializeRequest } from '../utils/logging/serializeRequest' +import { serializeResponse } from '../utils/logging/serializeResponse' import { matchRequestUrl, Match, @@ -106,14 +106,17 @@ export class HttpHandler extends RequestHandler< ) } - async parse(request: Request, resolutionContext?: ResponseResolutionContext) { - const url = new URL(request.url) + async parse(args: { + request: Request + resolutionContext?: ResponseResolutionContext + }) { + const url = new URL(args.request.url) const match = matchRequestUrl( url, this.info.path, - resolutionContext?.baseUrl, + args.resolutionContext?.baseUrl, ) - const cookies = getAllRequestCookies(request) + const cookies = getAllRequestCookies(args.request) return { match, @@ -121,9 +124,9 @@ export class HttpHandler extends RequestHandler< } } - predicate(request: Request, parsedResult: HttpRequestParsedResult) { - const hasMatchingMethod = this.matchMethod(request.method) - const hasMatchingUrl = parsedResult.match.matches + predicate(args: { request: Request; parsedResult: HttpRequestParsedResult }) { + const hasMatchingMethod = this.matchMethod(args.request.method) + const hasMatchingUrl = args.parsedResult.match.matches return hasMatchingMethod && hasMatchingUrl } @@ -133,26 +136,26 @@ export class HttpHandler extends RequestHandler< : isStringEqual(this.info.method, actualMethod) } - protected extendInfo( - _request: Request, - parsedResult: HttpRequestParsedResult, - ) { + protected extendResolverArgs(args: { + request: Request + parsedResult: HttpRequestParsedResult + }) { return { - params: parsedResult.match?.params || {}, - cookies: parsedResult.cookies, + params: args.parsedResult.match?.params || {}, + cookies: args.parsedResult.cookies, } } - async log(request: Request, response: Response) { - const publicUrl = getPublicUrlFromRequest(request) - const loggedRequest = await requestToLoggableObject(request) - const loggedResponse = await responseToLoggableObject(response) + async log(args: { request: Request; response: Response }) { + const publicUrl = getPublicUrlFromRequest(args.request) + const loggedRequest = await serializeRequest(args.request) + const loggedResponse = await serializeResponse(args.response) const statusColor = getStatusCodeColor(loggedResponse.status) console.groupCollapsed( devUtils.formatMessage('%s %s %s (%c%s%c)'), getTimestamp(), - request.method, + args.request.method, publicUrl, `color:${statusColor}`, `${loggedResponse.status} ${loggedResponse.statusText}`, diff --git a/src/core/handlers/RequestHandler.ts b/src/core/handlers/RequestHandler.ts index f3d6269ca..917d6edc4 100644 --- a/src/core/handlers/RequestHandler.ts +++ b/src/core/handlers/RequestHandler.ts @@ -120,52 +120,57 @@ export abstract class RequestHandler< } /** - * Determine if the captured request should be mocked. + * Determine if the intercepted request should be mocked. */ - abstract predicate( - request: Request, - parsedResult: ParsedResult, - resolutionContext?: ResponseResolutionContext, - ): boolean + abstract predicate(args: { + request: Request + parsedResult: ParsedResult + resolutionContext?: ResponseResolutionContext + }): boolean /** * Print out the successfully handled request. */ - abstract log( - request: Request, - response: Response, - parsedResult: ParsedResult, - ): void + abstract log(args: { + request: Request + response: Response + parsedResult: ParsedResult + }): void /** - * Parse the captured request to extract additional information from it. + * Parse the intercepted request to extract additional information from it. * Parsed result is then exposed to other methods of this request handler. */ - async parse( - _request: Request, - _resolutionContext?: ResponseResolutionContext, - ): Promise { + async parse(_args: { + request: Request + resolutionContext?: ResponseResolutionContext + }): Promise { return {} as ParsedResult } /** * Test if this handler matches the given request. */ - public async test( - request: Request, - resolutionContext?: ResponseResolutionContext, - ): Promise { - return this.predicate( - request, - await this.parse(request.clone(), resolutionContext), - resolutionContext, - ) + public async test(args: { + request: Request + resolutionContext?: ResponseResolutionContext + }): Promise { + const parsedResult = await this.parse({ + request: args.request, + resolutionContext: args.resolutionContext, + }) + + return this.predicate({ + request: args.request, + parsedResult, + resolutionContext: args.resolutionContext, + }) } - protected extendInfo( - _request: Request, - _parsedResult: ParsedResult, - ): ResolverExtras { + protected extendResolverArgs(_args: { + request: Request + parsedResult: ParsedResult + }): ResolverExtras { return {} as ResolverExtras } @@ -173,30 +178,32 @@ export abstract class RequestHandler< * Execute this request handler and produce a mocked response * using the given resolver function. */ - public async run( - request: StrictRequest, - resolutionContext?: ResponseResolutionContext, - ): Promise | null> { + public async run(args: { + request: StrictRequest + resolutionContext?: ResponseResolutionContext + }): Promise | null> { if (this.isUsed && this.options?.once) { return null } - const mainRequestRef = request.clone() + // Clone the request instance before it's passed to the handler phases + // and the response resolver so we can always read it for logging. + const mainRequestRef = args.request.clone() // Immediately mark the handler as used. // Can't await the resolver to be resolved because it's potentially // asynchronous, and there may be multiple requests hitting this handler. this.isUsed = true - const parsedResult = await this.parse( - mainRequestRef.clone(), - resolutionContext, - ) - const shouldInterceptRequest = this.predicate( - mainRequestRef.clone(), + const parsedResult = await this.parse({ + request: args.request, + resolutionContext: args.resolutionContext, + }) + const shouldInterceptRequest = this.predicate({ + request: args.request, parsedResult, - resolutionContext, - ) + resolutionContext: args.resolutionContext, + }) if (!shouldInterceptRequest) { return null @@ -206,19 +213,22 @@ export abstract class RequestHandler< // since it can be both an async function and a generator. const executeResolver = this.wrapResolver(this.resolver) - const resolverExtras = this.extendInfo(request, parsedResult) + const resolverExtras = this.extendResolverArgs({ + request: args.request, + parsedResult, + }) const mockedResponse = (await executeResolver({ ...resolverExtras, - request, + request: args.request, })) as Response - const executionResult = this.createExecutionResult( + const executionResult = this.createExecutionResult({ // Pass the cloned request to the result so that logging // and other consumers could read its body once more. - mainRequestRef, + request: mainRequestRef, + response: mockedResponse, parsedResult, - mockedResponse, - ) + }) return executionResult } @@ -272,16 +282,16 @@ export abstract class RequestHandler< } } - private createExecutionResult( - request: Request, - parsedResult: ParsedResult, - response?: Response, - ): RequestHandlerExecutionResult { + private createExecutionResult(args: { + request: Request + parsedResult: ParsedResult + response?: Response + }): RequestHandlerExecutionResult { return { handler: this, - parsedResult, - request, - response, + request: args.request, + response: args.response, + parsedResult: args.parsedResult, } } } diff --git a/src/core/http.ts b/src/core/http.ts index f268c3105..d4f7c58f8 100644 --- a/src/core/http.ts +++ b/src/core/http.ts @@ -30,6 +30,15 @@ function createHttpHandler( } } +/** + * A namespace to intercept and mock HTTP requests. + * + * @example + * http.get('/user', resolver) + * http.post('/post/:id', resolver) + * + * @see {@link https://mswjs.io/docs/api/http `http` API reference} + */ export const http = { all: createHttpHandler(/.+/), head: createHttpHandler(HttpMethods.HEAD), diff --git a/src/core/index.ts b/src/core/index.ts index 9deaa86d5..d7bf7f968 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -24,6 +24,7 @@ export type { ResponseResolver, ResponseResolverReturnType, AsyncResponseResolverReturnType, + RequestHandlerOptions, DefaultBodyType, DefaultRequestMultipartBody, } from './handlers/RequestHandler' diff --git a/src/core/passthrough.ts b/src/core/passthrough.ts index e6f6e08ec..d67afdde1 100644 --- a/src/core/passthrough.ts +++ b/src/core/passthrough.ts @@ -1,5 +1,5 @@ /** - * Performs the captured request as-is. + * Performs the intercepted request as-is. * * This stops request handler lookup so no other handlers * can affect this request past this point. @@ -9,6 +9,8 @@ * http.get('/resource', () => { * return passthrough() * }) + * + * @see {@link https://mswjs.io/docs/api/passthrough `passthrough()` API reference} */ export function passthrough(): Response { return new Response(null, { diff --git a/src/core/utils/getResponse.ts b/src/core/utils/getResponse.ts index 2644ebf28..131804d8b 100644 --- a/src/core/utils/getResponse.ts +++ b/src/core/utils/getResponse.ts @@ -5,7 +5,7 @@ import { export interface ResponseLookupResult { handler: RequestHandler - parsedRequest?: any + parsedResult?: any response?: Response } @@ -25,7 +25,7 @@ export const getResponse = async >( let result: RequestHandlerExecutionResult | null = null for (const handler of handlers) { - result = await handler.run(request, resolutionContext) + result = await handler.run({ request, resolutionContext }) // If the handler produces some result for this request, // it automatically becomes matching. @@ -46,7 +46,7 @@ export const getResponse = async >( if (matchingHandler) { return { handler: matchingHandler, - parsedRequest: result?.parsedResult, + parsedResult: result?.parsedResult, response: result?.response, } } diff --git a/src/core/utils/handleRequest.test.ts b/src/core/utils/handleRequest.test.ts index 5c5b7c613..a89bd00f3 100644 --- a/src/core/utils/handleRequest.test.ts +++ b/src/core/utils/handleRequest.test.ts @@ -1,7 +1,6 @@ /** * @jest-environment jsdom */ -import { Headers } from 'headers-polyfill' import { Emitter } from 'strict-event-emitter' import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions' import { RequestHandler } from '../handlers/RequestHandler' @@ -188,7 +187,7 @@ test('returns the mocked response for a request with a matching request handler' handler: handlers[0], response: mockedResponse, request, - parsedRequest: { + parsedResult: { match: { matches: true, params: {} }, cookies: {}, }, @@ -223,7 +222,7 @@ test('returns the mocked response for a request with a matching request handler' expect(lookupResultParam).toEqual({ handler: lookupResult.handler, - parsedRequest: lookupResult.parsedRequest, + parsedResult: lookupResult.parsedResult, response: expect.objectContaining({ status: lookupResult.response.status, statusText: lookupResult.response.statusText, @@ -253,7 +252,7 @@ test('returns a transformed response if the "transformResponse" option is provid handler: handlers[0], response: mockedResponse, request, - parsedRequest: { + parsedResult: { match: { matches: true, params: {} }, cookies: {}, }, @@ -306,7 +305,7 @@ test('returns a transformed response if the "transformResponse" option is provid expect(lookupResultParam).toEqual({ handler: lookupResult.handler, - parsedRequest: lookupResult.parsedRequest, + parsedResult: lookupResult.parsedResult, response: expect.objectContaining({ status: lookupResult.response.status, statusText: lookupResult.response.statusText, diff --git a/src/core/utils/handleRequest.ts b/src/core/utils/handleRequest.ts index 24f40d95b..f70002179 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -6,7 +6,6 @@ import { RequiredDeep } from '../typeUtils' import { ResponseLookupResult, getResponse } from './getResponse' import { onUnhandledRequest } from './request/onUnhandledRequest' import { readResponseCookies } from './request/readResponseCookies' -import { RemoteRequestHandler } from '../handlers/RemoteRequestHandler' export interface HandleRequestOptions { /** @@ -82,22 +81,11 @@ export async function handleRequest( return } - const { handler, response } = lookupResult.data + const { response } = lookupResult.data // When the handled request returned no mocked response, warn the developer, // as it may be an oversight on their part. Perform the request as-is. if (!response) { - // Prevent the internal request handler for "setupRemoteServer" to mark - // requests as handled. Also, prevent it from marking internal requests, - // such as WebSocket internal events, as handled. - if ( - handler instanceof RemoteRequestHandler && - request.headers.get('x-msw-request-type') !== 'internal-request' - ) { - await onUnhandledRequest(request, handlers, options.onUnhandledRequest) - emitter.emit('request:unhandled', { request, requestId }) - } - emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return diff --git a/src/core/utils/internal/parseGraphQLRequest.test.ts b/src/core/utils/internal/parseGraphQLRequest.test.ts index d2a4564b3..7bb624d96 100644 --- a/src/core/utils/internal/parseGraphQLRequest.test.ts +++ b/src/core/utils/internal/parseGraphQLRequest.test.ts @@ -3,7 +3,6 @@ */ import { encodeBuffer } from '@mswjs/interceptors' import { OperationTypeNode } from 'graphql' -import { Headers } from 'headers-polyfill' import { ParsedGraphQLRequest, parseGraphQLRequest, diff --git a/src/core/utils/logging/responseToLoggableObject.test.ts b/src/core/utils/logging/responseToLoggableObject.test.ts index 0168c6512..15efaa063 100644 --- a/src/core/utils/logging/responseToLoggableObject.test.ts +++ b/src/core/utils/logging/responseToLoggableObject.test.ts @@ -8,7 +8,6 @@ it('serializes response without body', async () => { const result = await responseToLoggableObject(new Response(null)) expect(result.status).toBe(200) - expect(result.statusText).toBe('OK') expect(result.headers).toEqual({}) expect(result.body).toBe('') }) diff --git a/src/core/utils/logging/serializeRequest.test.ts b/src/core/utils/logging/serializeRequest.test.ts new file mode 100644 index 000000000..aac41d891 --- /dev/null +++ b/src/core/utils/logging/serializeRequest.test.ts @@ -0,0 +1,23 @@ +import { encodeBuffer } from '@mswjs/interceptors' +import { serializeRequest } from './serializeRequest' + +test('serializes given Request instance into a plain object', async () => { + const request = await serializeRequest( + new Request(new URL('http://test.mswjs.io/user'), { + method: 'POST', + headers: new Headers({ + 'Content-Type': 'text/plain', + 'X-Header': 'secret', + }), + body: encodeBuffer('text-body'), + }), + ) + + expect(request.method).toBe('POST') + expect(request.url.href).toBe('http://test.mswjs.io/user') + expect(request.headers).toEqual({ + 'content-type': 'text/plain', + 'x-header': 'secret', + }) + expect(request.body).toBe('text-body') +}) diff --git a/src/core/utils/logging/serializeRequest.ts b/src/core/utils/logging/serializeRequest.ts new file mode 100644 index 000000000..a2c2afd01 --- /dev/null +++ b/src/core/utils/logging/serializeRequest.ts @@ -0,0 +1,23 @@ +export interface LoggedRequest { + url: URL + method: string + headers: Record + body: string +} + +/** + * Formats a mocked request for introspection in browser's console. + */ +export async function serializeRequest( + request: Request, +): Promise { + const requestClone = request.clone() + const requestText = await requestClone.text() + + return { + url: new URL(request.url), + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + body: requestText, + } +} diff --git a/src/core/utils/logging/serializeResponse.test.ts b/src/core/utils/logging/serializeResponse.test.ts new file mode 100644 index 000000000..61a903286 --- /dev/null +++ b/src/core/utils/logging/serializeResponse.test.ts @@ -0,0 +1,77 @@ +/** + * @jest-environment node + */ +import { encodeBuffer } from '@mswjs/interceptors' +import { serializeResponse } from './serializeResponse' + +it('serializes response without body', async () => { + const result = await serializeResponse(new Response(null)) + + expect(result.status).toBe(200) + expect(result.statusText).toBe('OK') + expect(result.headers).toEqual({}) + expect(result.body).toBe('') +}) + +it('serializes a plain text response', async () => { + const result = await serializeResponse( + new Response('hello world', { + status: 201, + statusText: 'Created', + headers: { + 'Content-Type': 'text/plain', + }, + }), + ) + + expect(result.status).toBe(201) + expect(result.statusText).toBe('Created') + expect(result.headers).toEqual({ + 'content-type': 'text/plain', + }) + expect(result.body).toBe('hello world') +}) + +it('serializes a JSON response', async () => { + const response = new Response(JSON.stringify({ users: ['John'] }), { + headers: { + 'Content-Type': 'application/json', + }, + }) + const result = await serializeResponse(response) + + expect(result.headers).toEqual({ + 'content-type': 'application/json', + }) + expect(result.body).toBe(JSON.stringify({ users: ['John'] })) +}) + +it('serializes a ArrayBuffer response', async () => { + const data = encodeBuffer('hello world') + const response = new Response(data) + const result = await serializeResponse(response) + + expect(result.body).toBe('hello world') +}) + +it('serializes a Blob response', async () => { + const response = new Response(new Blob(['hello world'])) + const result = await serializeResponse(response) + + expect(result.body).toBe('hello world') +}) + +it('serializes a FormData response', async () => { + const data = new FormData() + data.set('firstName', 'Alice') + data.set('age', '32') + const response = new Response(data) + const result = await serializeResponse(response) + + expect(result.body).toContain( + `Content-Disposition: form-data; name="firstName"\r\n\r\nAlice`, + ) + expect(result.body).toContain( + `Content-Disposition: form-data; name="age"\r\n\r\n32`, + ) +}) diff --git a/src/core/utils/logging/serializeResponse.ts b/src/core/utils/logging/serializeResponse.ts new file mode 100644 index 000000000..754dbd32e --- /dev/null +++ b/src/core/utils/logging/serializeResponse.ts @@ -0,0 +1,31 @@ +import statuses from '@bundled-es-modules/statuses' + +const { message } = statuses + +export interface SerializedResponse { + status: number + statusText: string + headers: Record + body: string +} + +export async function serializeResponse( + response: Response, +): Promise { + const responseClone = response.clone() + const responseText = await responseClone.text() + + // Normalize the response status and status text when logging + // since the default Response instance doesn't infer status texts + // from status codes. This has no effect on the actual response instance. + const responseStatus = responseClone.status || 200 + const responseStatusText = + responseClone.statusText || message[responseStatus] || 'OK' + + return { + status: responseStatus, + statusText: responseStatusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseText, + } +} diff --git a/src/core/utils/request/onUnhandledRequest.test.ts b/src/core/utils/request/onUnhandledRequest.test.ts index 48c0123cd..5649ea866 100644 --- a/src/core/utils/request/onUnhandledRequest.test.ts +++ b/src/core/utils/request/onUnhandledRequest.test.ts @@ -12,7 +12,7 @@ const resolver: ResponseResolver = () => void 0 const fixtures = { warningWithoutSuggestions: `\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • GET /api @@ -20,7 +20,7 @@ If you still wish to intercept this unhandled request, please create a request h Read more: https://mswjs.io/docs/getting-started/mocks`, errorWithoutSuggestions: `\ -[MSW] Error: captured a request without a matching request handler: +[MSW] Error: intercepted a request without a matching request handler: • GET /api @@ -28,7 +28,7 @@ If you still wish to intercept this unhandled request, please create a request h Read more: https://mswjs.io/docs/getting-started/mocks`, warningWithSuggestions: (suggestions: string) => `\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • GET /api diff --git a/src/core/utils/request/onUnhandledRequest.ts b/src/core/utils/request/onUnhandledRequest.ts index 3230bbcdf..89a571360 100644 --- a/src/core/utils/request/onUnhandledRequest.ts +++ b/src/core/utils/request/onUnhandledRequest.ts @@ -188,7 +188,7 @@ export async function onUnhandledRequest( const handlerSuggestion = generateHandlerSuggestion() const messageTemplate = [ - `captured a request without a matching request handler:`, + `intercepted a request without a matching request handler:`, ` \u2022 ${requestHeader}`, handlerSuggestion, `\ diff --git a/src/core/utils/toResponseInit.ts b/src/core/utils/toResponseInit.ts index 986ad0fc7..e7e6f9a7a 100644 --- a/src/core/utils/toResponseInit.ts +++ b/src/core/utils/toResponseInit.ts @@ -1,9 +1,7 @@ -import { flattenHeadersObject, headersToObject } from 'headers-polyfill' - export function toResponseInit(response: Response): ResponseInit { return { status: response.status, statusText: response.statusText, - headers: flattenHeadersObject(headersToObject(response.headers)), + headers: Object.fromEntries(response.headers.entries()), } } diff --git a/src/mockServiceWorker.js b/src/mockServiceWorker.js index 41ee9a29d..faa67b6c8 100644 --- a/src/mockServiceWorker.js +++ b/src/mockServiceWorker.js @@ -107,7 +107,7 @@ self.addEventListener('fetch', function (event) { } // Generate unique request ID. - const requestId = Math.random().toString(16).slice(2) + const requestId = crypto.randomUUID() event.respondWith(handleRequest(event, requestId)) }) diff --git a/src/native/index.ts b/src/native/index.ts index e2448e9c7..245c1fb93 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -5,7 +5,8 @@ import { SetupServerApi } from '../node/SetupServerApi' /** * Sets up a requests interception in React Native with the given request handlers. * @param {RequestHandler[]} handlers List of request handlers. - * @see {@link https://mswjs.io/docs/api/setup-server `setupServer`} + * + * @see {@link https://mswjs.io/docs/api/setup-server `setupServer()` API reference} */ export function setupServer( ...handlers: Array diff --git a/src/node/SetupServerApi.ts b/src/node/SetupServerApi.ts index 746a4c263..05530a220 100644 --- a/src/node/SetupServerApi.ts +++ b/src/node/SetupServerApi.ts @@ -1,3 +1,4 @@ +import { setMaxListeners, defaultMaxListeners } from 'node:events' import { invariant } from 'outvariant' import { BatchInterceptor, @@ -20,6 +21,7 @@ import { serializeEventPayload, } from '~/core/utils/internal/emitterUtils' import { RemoteRequestHandler } from '~/core/handlers/RemoteRequestHandler' +import { isNodeException } from './utils/isNodeException' const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', @@ -62,6 +64,41 @@ export class SetupServerApi */ private init(): void { this.interceptor.on('request', async ({ request, requestId }) => { + /** + * @note React Native doesn't have "node:events". + */ + if (typeof setMaxListeners === 'function') { + // Bump the maximum number of event listeners on the + // request's "AbortSignal". This prepares the request + // for each request handler cloning it at least once. + // Note that cloning a request automatically appends a + // new "abort" event listener to the parent request's + // "AbortController" so if the parent aborts, all the + // clones are automatically aborted. + try { + setMaxListeners( + Math.max(defaultMaxListeners, this.currentHandlers.length), + request.signal, + ) + } catch (error: unknown) { + /** + * @note Mock environments (JSDOM, ...) are not able to implement an internal + * "kIsNodeEventTarget" Symbol that Node.js uses to identify Node.js `EventTarget`s. + * `setMaxListeners` throws an error for non-Node.js `EventTarget`s. + * At the same time, mock environments are also not able to implement the + * internal "events.maxEventTargetListenersWarned" Symbol, which results in + * "MaxListenersExceededWarning" not being printed by Node.js for those anyway. + * The main reason for using `setMaxListeners` is to suppress these warnings in Node.js, + * which won't be printed anyway if `setMaxListeners` fails. + */ + if ( + !(isNodeException(error) && error.code === 'ERR_INVALID_ARG_TYPE') + ) { + throw error + } + } + } + const response = await handleRequest( request, requestId, diff --git a/src/node/glossary.ts b/src/node/glossary.ts index 32a55ebcf..0edda3ce8 100644 --- a/src/node/glossary.ts +++ b/src/node/glossary.ts @@ -12,39 +12,51 @@ import { export interface SetupServer { /** * Starts requests interception based on the previously provided request handlers. - * @see {@link https://mswjs.io/docs/api/setup-server/listen `server.listen()`} + * + * @see {@link https://mswjs.io/docs/api/setup-server/listen `server.listen()` API reference} */ listen(options?: PartialDeep): void /** * Stops requests interception by restoring all augmented modules. - * @see {@link https://mswjs.io/docs/api/setup-server/close `server.close()`} + * + * @see {@link https://mswjs.io/docs/api/setup-server/close `server.close()` API reference} */ close(): void /** * Prepends given request handlers to the list of existing handlers. - * @see {@link https://mswjs.io/docs/api/setup-server/use `server.use()`} + * + * @see {@link https://mswjs.io/docs/api/setup-server/use `server.use()` API reference} */ use(...handlers: Array): void /** * Marks all request handlers that respond using `res.once()` as unused. - * @see {@link https://mswjs.io/docs/api/setup-server/restore-handlers `server.restore-handlers()`} + * + * @see {@link https://mswjs.io/docs/api/setup-server/restore-handlers `server.restore-handlers()` API reference} */ restoreHandlers(): void /** * Resets request handlers to the initial list given to the `setupServer` call, or to the explicit next request handlers list, if given. - * @see {@link https://mswjs.io/docs/api/setup-server/reset-handlers `server.reset-handlers()`} + * + * @see {@link https://mswjs.io/docs/api/setup-server/reset-handlers `server.reset-handlers()` API reference} */ resetHandlers(...nextHandlers: Array): void /** * Returns a readonly list of currently active request handlers. - * @see {@link https://mswjs.io/docs/api/setup-server/list-handlers `server.listHandlers()`} + * + * @see {@link https://mswjs.io/docs/api/setup-server/list-handlers `server.listHandlers()` API reference} */ listHandlers(): ReadonlyArray> + /** + * Life-cycle events. + * Life-cycle events allow you to subscribe to the internal library events occurring during the request/response handling. + * + * @see {@link https://mswjs.io/docs/api/life-cycle-events Life-cycle Events API reference} + */ events: LifeCycleEventEmitter } diff --git a/src/node/setupServer.ts b/src/node/setupServer.ts index 6e88f9cb3..06b2546af 100644 --- a/src/node/setupServer.ts +++ b/src/node/setupServer.ts @@ -8,7 +8,8 @@ import { SetupServerApi } from './SetupServerApi' /** * Sets up a requests interception in Node.js with the given request handlers. * @param {RequestHandler[]} handlers List of request handlers. - * @see {@link https://mswjs.io/docs/api/setup-server `setupServer`} + * + * @see {@link https://mswjs.io/docs/api/setup-server `setupServer()` API reference} */ export const setupServer = ( ...handlers: Array diff --git a/src/node/utils/isNodeException.ts b/src/node/utils/isNodeException.ts new file mode 100644 index 000000000..268e5b8a5 --- /dev/null +++ b/src/node/utils/isNodeException.ts @@ -0,0 +1,10 @@ +/** + * Determines if the given value is a Node.js exception. + * Node.js exceptions have additional information, like + * the `code` and `errno` properties. + */ +export function isNodeException( + error: unknown, +): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error +} diff --git a/test/browser/graphql-api/anonymous-operation.test.ts b/test/browser/graphql-api/anonymous-operation.test.ts index c5702e34f..efc6a2479 100644 --- a/test/browser/graphql-api/anonymous-operation.test.ts +++ b/test/browser/graphql-api/anonymous-operation.test.ts @@ -67,7 +67,7 @@ test('does not warn on anonymous GraphQL operation when no GraphQL handlers are // This has nothing to do with the operation being anonymous. expect(consoleSpy.get('warning')).toEqual([ `\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • anonymous query (POST ${endpointUrl}) @@ -76,7 +76,7 @@ Read more: https://mswjs.io/docs/getting-started/mocks`, ]) }) - // // Must print the warning because anonymous operations cannot be captured + // // Must print the warning because anonymous operations cannot be intercepted // // using standard "graphql.query()" and "graphql.mutation()" handlers. // await waitFor(() => { // expect(consoleSpy.get('warning')).toEqual( @@ -205,6 +205,6 @@ test('does not print a warning on anonymous GraphQL operation handled by "graphq }) // Must not print any warnings because a permissive "graphql.operation()" - // handler was used to capture and mock the anonymous GraphQL operation. + // handler was used to intercept and mock the anonymous GraphQL operation. expect(consoleSpy.get('warning')).toBeUndefined() }) diff --git a/test/browser/graphql-api/mutation.test.ts b/test/browser/graphql-api/mutation.test.ts index 791f9ccbf..4a5a9af77 100644 --- a/test/browser/graphql-api/mutation.test.ts +++ b/test/browser/graphql-api/mutation.test.ts @@ -49,7 +49,7 @@ test('sends a mocked response to a GraphQL mutation', async ({ }) }) -test('prints a warning when captured an anonymous GraphQL mutation', async ({ +test('prints a warning when intercepted an anonymous GraphQL mutation', async ({ loadExample, spyOnConsole, query, diff --git a/test/browser/graphql-api/query.test.ts b/test/browser/graphql-api/query.test.ts index 44d3244b9..b1dbc6171 100644 --- a/test/browser/graphql-api/query.test.ts +++ b/test/browser/graphql-api/query.test.ts @@ -85,7 +85,7 @@ test('mocks a GraphQL query issued with a POST request', async ({ }) }) -test('prints a warning when captured an anonymous GraphQL query', async ({ +test('prints a warning when intercepted an anonymous GraphQL query', async ({ loadExample, spyOnConsole, query, diff --git a/test/browser/graphql-api/response-patching.mocks.ts b/test/browser/graphql-api/response-patching.mocks.ts index 03473e939..5db10c3bc 100644 --- a/test/browser/graphql-api/response-patching.mocks.ts +++ b/test/browser/graphql-api/response-patching.mocks.ts @@ -11,8 +11,7 @@ interface GetUserQuery { const worker = setupWorker( graphql.query('GetUser', async ({ request }) => { - const fetchArgs = await bypass(request) - const originalResponse = await fetch(...fetchArgs) + const originalResponse = await fetch(bypass(request)) const originalJson = await originalResponse.json() return HttpResponse.json({ diff --git a/test/browser/msw-api/setup-worker/fallback-mode/fallback-mode.test.ts b/test/browser/msw-api/setup-worker/fallback-mode/fallback-mode.test.ts index a5fcfe8f5..b1ffc8bb4 100644 --- a/test/browser/msw-api/setup-worker/fallback-mode/fallback-mode.test.ts +++ b/test/browser/msw-api/setup-worker/fallback-mode/fallback-mode.test.ts @@ -144,7 +144,7 @@ test('warns on the unhandled request by default', async ({ expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • GET ${server.http.url('/unknown-resource')} diff --git a/test/browser/msw-api/setup-worker/start/on-unhandled-request/callback-print.test.ts b/test/browser/msw-api/setup-worker/start/on-unhandled-request/callback-print.test.ts index de8f756e8..da68cdfbe 100644 --- a/test/browser/msw-api/setup-worker/start/on-unhandled-request/callback-print.test.ts +++ b/test/browser/msw-api/setup-worker/start/on-unhandled-request/callback-print.test.ts @@ -24,7 +24,7 @@ test('executes a default "warn" strategy in a custom callback', async ({ expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • GET https://mswjs.io/use-warn @@ -58,7 +58,7 @@ test('executes a default "error" strategy in a custom callback', async ({ expect(consoleSpy.get('error')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Error: captured a request without a matching request handler: +[MSW] Error: intercepted a request without a matching request handler: • GET https://mswjs.io/use-error diff --git a/test/browser/msw-api/setup-worker/start/on-unhandled-request/default.test.ts b/test/browser/msw-api/setup-worker/start/on-unhandled-request/default.test.ts index 46cafea89..ebaff06b9 100644 --- a/test/browser/msw-api/setup-worker/start/on-unhandled-request/default.test.ts +++ b/test/browser/msw-api/setup-worker/start/on-unhandled-request/default.test.ts @@ -14,7 +14,7 @@ test('warns on unhandled requests by default', async ({ expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringMatching( - /\[MSW\] Warning: captured a request without a matching request handler/, + /\[MSW\] Warning: intercepted a request without a matching request handler/, ), ]), ) diff --git a/test/browser/msw-api/setup-worker/start/on-unhandled-request/suggestions.graphql.test.ts b/test/browser/msw-api/setup-worker/start/on-unhandled-request/suggestions.graphql.test.ts index 1bbdfd001..77bd1c96c 100644 --- a/test/browser/msw-api/setup-worker/start/on-unhandled-request/suggestions.graphql.test.ts +++ b/test/browser/msw-api/setup-worker/start/on-unhandled-request/suggestions.graphql.test.ts @@ -46,7 +46,7 @@ test.describe('GraphQL API', () => { expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • query PaymentHistory (POST /graphql) @@ -92,7 +92,7 @@ Read more: https://mswjs.io/docs/getting-started/mocks`), expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • query GetUsers (POST /graphql) @@ -140,7 +140,7 @@ Read more: https://mswjs.io/docs/getting-started/mocks`), expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • query SubmitCheckout (POST /graphql) @@ -188,7 +188,7 @@ Read more: https://mswjs.io/docs/getting-started/mocks`), expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • query ActiveUsers (POST /graphql) diff --git a/test/browser/msw-api/setup-worker/start/on-unhandled-request/suggestions.rest.test.ts b/test/browser/msw-api/setup-worker/start/on-unhandled-request/suggestions.rest.test.ts index 0bef29269..cec86e941 100644 --- a/test/browser/msw-api/setup-worker/start/on-unhandled-request/suggestions.rest.test.ts +++ b/test/browser/msw-api/setup-worker/start/on-unhandled-request/suggestions.rest.test.ts @@ -32,7 +32,7 @@ test.describe('REST API', () => { expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • GET /user-details @@ -61,7 +61,7 @@ Read more: https://mswjs.io/docs/getting-started/mocks`), expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • GET /users @@ -97,7 +97,7 @@ Read more: https://mswjs.io/docs/getting-started/mocks`), expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • POST /users @@ -131,7 +131,7 @@ Read more: https://mswjs.io/docs/getting-started/mocks`), expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • GET /pamyents diff --git a/test/browser/msw-api/setup-worker/start/on-unhandled-request/warn.test.ts b/test/browser/msw-api/setup-worker/start/on-unhandled-request/warn.test.ts index 2a5502de4..7c6a68830 100644 --- a/test/browser/msw-api/setup-worker/start/on-unhandled-request/warn.test.ts +++ b/test/browser/msw-api/setup-worker/start/on-unhandled-request/warn.test.ts @@ -15,7 +15,7 @@ test('warns on an unhandled REST API request with an absolute URL', async ({ expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • GET https://mswjs.io/non-existing-page @@ -40,7 +40,7 @@ test('warns on an unhandled REST API request with a relative URL', async ({ expect(consoleSpy.get('warning')).toEqual( expect.arrayContaining([ expect.stringContaining(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • GET /user-details @@ -65,7 +65,7 @@ test('does not warn on request which handler explicitly returns no mocked respon expect(consoleSpy.get('warning')).toEqual( expect.not.arrayContaining([ expect.stringContaining( - '[MSW] Warning: captured a request without a matching request handler', + '[MSW] Warning: intercepted a request without a matching request handler', ), ]), ) @@ -86,7 +86,7 @@ test('does not warn on request which handler implicitly returns no mocked respon expect(consoleSpy.get('warning')).toEqual( expect.not.arrayContaining([ expect.stringContaining( - '[MSW] Warning: captured a request without a matching request handler', + '[MSW] Warning: intercepted a request without a matching request handler', ), ]), ) diff --git a/test/browser/msw-api/setup-worker/start/quiet.test.ts b/test/browser/msw-api/setup-worker/start/quiet.test.ts index 0d4e3c61d..12d1e830e 100644 --- a/test/browser/msw-api/setup-worker/start/quiet.test.ts +++ b/test/browser/msw-api/setup-worker/start/quiet.test.ts @@ -7,7 +7,7 @@ declare namespace window { } } -test('does not log the captured request when the "quiet" option is set to "true"', async ({ +test('does not log the intercepted request when the "quiet" option is set to "true"', async ({ loadExample, spyOnConsole, page, diff --git a/test/browser/rest-api/cookies-request.mocks.ts b/test/browser/rest-api/cookies-request.mocks.ts index 06ba33770..18b89e3e2 100644 --- a/test/browser/rest-api/cookies-request.mocks.ts +++ b/test/browser/rest-api/cookies-request.mocks.ts @@ -2,7 +2,7 @@ import { http, HttpResponse } from 'msw' import { setupWorker } from 'msw/browser' const worker = setupWorker( - // Use wildcard so that we capture any "GET /user" requests + // Use wildcard so that we intercept any "GET /user" requests // regardless of the origin, and can assert "same-origin" credentials. http.get('*/user', ({ cookies }) => { return HttpResponse.json({ cookies }) diff --git a/test/browser/rest-api/headers-multiple.test.ts b/test/browser/rest-api/headers-multiple.test.ts index 78410fd8e..3d5d0ddb3 100644 --- a/test/browser/rest-api/headers-multiple.test.ts +++ b/test/browser/rest-api/headers-multiple.test.ts @@ -1,4 +1,3 @@ -import { Headers } from 'headers-polyfill' import { test, expect } from '../playwright.extend' const EXAMPLE_PATH = require.resolve('./headers-multiple.mocks.ts') @@ -14,7 +13,7 @@ test('receives all headers from the request header with multiple values', async const res = await fetch('https://test.mswjs.io', { method: 'POST', - headers: headers.all(), + headers: Object.fromEntries(headers.entries()), }) const status = res.status() const body = await res.json() diff --git a/test/browser/rest-api/logging.test.ts b/test/browser/rest-api/logging.test.ts index a0ef1b8bc..ff230dce8 100644 --- a/test/browser/rest-api/logging.test.ts +++ b/test/browser/rest-api/logging.test.ts @@ -2,7 +2,7 @@ import { test, expect } from '../playwright.extend' import { StatusCodeColor } from '../../../src/core/utils/logging/getStatusCodeColor' import { waitFor } from '../../support/waitFor' -test('prints a captured request info into browser console', async ({ +test('prints the intercepted request info into browser console', async ({ loadExample, spyOnConsole, fetch, diff --git a/test/browser/rest-api/response-patching.mocks.ts b/test/browser/rest-api/response-patching.mocks.ts index 05643d431..1ca6bccfb 100644 --- a/test/browser/rest-api/response-patching.mocks.ts +++ b/test/browser/rest-api/response-patching.mocks.ts @@ -3,8 +3,7 @@ import { setupWorker } from 'msw/browser' const worker = setupWorker( http.get('*/user', async ({ request }) => { - const fetchArgs = await bypass(request.url) - const originalResponse = await fetch(...fetchArgs) + const originalResponse = await fetch(bypass(request.url)) const body = await originalResponse.json() return HttpResponse.json( @@ -22,8 +21,7 @@ const worker = setupWorker( }), http.get('*/repos/:owner/:repoName', async ({ request }) => { - const fetchArgs = await bypass(request) - const originalResponse = await fetch(...fetchArgs) + const originalResponse = await fetch(bypass(request)) const body = await originalResponse.json() return HttpResponse.json( @@ -41,11 +39,12 @@ const worker = setupWorker( http.get('*/headers', async ({ request }) => { const proxyUrl = new URL('/headers-proxy', request.url) - const fetchArgs = await bypass(proxyUrl, { - method: 'POST', - headers: request.headers, - }) - const originalResponse = await fetch(...fetchArgs) + const originalResponse = await fetch( + bypass(proxyUrl, { + method: 'POST', + headers: request.headers, + }), + ) const body = await originalResponse.json() return HttpResponse.json(body, { @@ -56,8 +55,7 @@ const worker = setupWorker( }), http.post('*/posts', async ({ request }) => { - const fetchArgs = await bypass(request) - const originalResponse = await fetch(...fetchArgs) + const originalResponse = await fetch(bypass(request)) const body = await originalResponse.json() return HttpResponse.json( @@ -75,8 +73,7 @@ const worker = setupWorker( }), http.get('*/posts', async ({ request }) => { - const fetchArgs = await bypass(request) - const originalResponse = await fetch(...fetchArgs) + const originalResponse = await fetch(bypass(request)) const body = await originalResponse.json() return HttpResponse.json( @@ -93,8 +90,7 @@ const worker = setupWorker( }), http.head('*/posts', async ({ request }) => { - const fetchArgs = await bypass(request) - const originalResponse = await fetch(...fetchArgs) + const originalResponse = await fetch(bypass(request)) return HttpResponse.json( { diff --git a/test/browser/rest-api/response/body/body-stream.mocks.ts b/test/browser/rest-api/response/body/body-stream.mocks.ts new file mode 100644 index 000000000..cd88c669f --- /dev/null +++ b/test/browser/rest-api/response/body/body-stream.mocks.ts @@ -0,0 +1,29 @@ +import { http, HttpResponse, delay } from 'msw' +import { setupWorker } from 'msw/browser' + +const encoder = new TextEncoder() +const chunks = ['hello', 'streaming', 'world'] + +const worker = setupWorker( + http.get('/stream', () => { + const stream = new ReadableStream({ + async start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)) + await delay(250) + } + + controller.close() + }, + }) + + return new HttpResponse(stream, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': chunks.join('').length.toString(), + }, + }) + }), +) + +worker.start() diff --git a/test/browser/rest-api/response/body/body-stream.test.ts b/test/browser/rest-api/response/body/body-stream.test.ts new file mode 100644 index 000000000..43b76d5a1 --- /dev/null +++ b/test/browser/rest-api/response/body/body-stream.test.ts @@ -0,0 +1,46 @@ +import { test, expect } from '../../../playwright.extend' + +test('responds with a mocked ReadableStream response', async ({ + loadExample, + page, +}) => { + await loadExample(require.resolve('./body-stream.mocks.ts')) + + const chunks = await page.evaluate(() => { + return fetch('/stream').then(async (res) => { + if (res.body === null) { + return [] + } + + const decoder = new TextDecoder() + const chunks: Array<{ text: string; timestamp: number }> = [] + const reader = res.body.getReader() + + while (true) { + const { value, done } = await reader.read() + + if (done) { + return chunks + } + + chunks.push({ + text: decoder.decode(value), + timestamp: Date.now(), + }) + } + }) + }) + + // Must stream the mocked response in three chunks. + const chunksText = chunks.map((chunk) => chunk.text) + expect(chunksText).toEqual(['hello', 'streaming', 'world']) + + const chunkDeltas = chunks.map((chunk, index) => { + const prevChunk = chunks[index - 1] + return prevChunk ? chunk.timestamp - prevChunk.timestamp : 0 + }) + + expect(chunkDeltas[0]).toBe(0) + expect(chunkDeltas[1]).toBeGreaterThanOrEqual(200) + expect(chunkDeltas[2]).toBeGreaterThanOrEqual(200) +}) diff --git a/test/jest.config.js b/test/jest.config.js index 6287110ef..1f8692b34 100644 --- a/test/jest.config.js +++ b/test/jest.config.js @@ -10,7 +10,6 @@ module.exports = { moduleNameMapper: { '^msw(.*)': '/../..$1', }, - setupFilesAfterEnv: ['/../../jest.setup.js'], testEnvironmentOptions: { // Force JSDOM to use the Node module resolution because we're still in Node.js. // Using browser resolution won't work by design because JSDOM is not a browser @@ -21,6 +20,7 @@ module.exports = { customExportConditions: [''], }, globals: { + fetch, Request, Response, TextEncoder, diff --git a/test/jest.setup.js b/test/jest.setup.js deleted file mode 100644 index 53f2852b9..000000000 --- a/test/jest.setup.js +++ /dev/null @@ -1,35 +0,0 @@ -const { TextEncoder, TextDecoder } = require('util') - -const PureURL = globalThis.URL - -globalThis.URL = function URL(url, base) { - try { - console.warn('URL', { url, base }) - const final = new PureURL(url, base) - return final - } catch (error) { - console.error('[URL DEBUGGER] Invalid URL:', { url, base }) - throw error - } -} - -/** - * @note Temporary global polyfills for Jest because it's - * ignoring Node.js defaults. - */ -Object.defineProperties(globalThis, { - TextDecoder: { value: TextDecoder }, - TextEncoder: { value: TextEncoder }, -}) - -const { Blob } = require('buffer') -const { Request, Response, Headers, File, FormData } = require('undici') - -Object.defineProperties(globalThis, { - Headers: { value: Headers }, - Request: { value: Request }, - Response: { value: Response }, - File: { value: File }, - Blob: { value: Blob }, - FormData: { value: FormData }, -}) diff --git a/test/node/graphql-api/response-patching.node.test.ts b/test/node/graphql-api/response-patching.node.test.ts index c2d836466..abf349fa7 100644 --- a/test/node/graphql-api/response-patching.node.test.ts +++ b/test/node/graphql-api/response-patching.node.test.ts @@ -9,8 +9,7 @@ import { createGraphQLClient, gql } from '../../support/graphql' const server = setupServer( graphql.query('GetUser', async ({ request }) => { - const requestInfo = await bypass(request) - const originalResponse = await fetch(...requestInfo) + const originalResponse = await fetch(bypass(request)) const { requestHeaders, queryResult } = await originalResponse.json() return HttpResponse.json({ diff --git a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback-throws.node.test.ts b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback-throws.node.test.ts index 8a44c0355..c941910c3 100644 --- a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback-throws.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback-throws.node.test.ts @@ -16,7 +16,7 @@ beforeAll(() => onUnhandledRequest(request) { /** * @fixme @todo For some reason, the exception from the "onUnhandledRequest" - * callback doesn't propagate to the captured request but instead is thrown + * callback doesn't propagate to the intercepted request but instead is thrown * in this test's context. */ throw new Error(`Custom error for ${request.method} ${request.url}`) diff --git a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/default.node.test.ts b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/default.node.test.ts index 53e0f121f..460c85e6b 100644 --- a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/default.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/default.node.test.ts @@ -30,7 +30,7 @@ test('warns on unhandled requests by default', async () => { expect(console.error).not.toBeCalled() expect(console.warn).toBeCalledWith(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • GET https://test.mswjs.io/ diff --git a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts index 47658b1ed..c6bb16f9f 100644 --- a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/error.node.test.ts @@ -62,7 +62,7 @@ test('errors on unhandled request when using the "error" value', async () => { `request to ${endpointUrl} failed, reason: [MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.`, ) expect(console.error) - .toHaveBeenCalledWith(`[MSW] Error: captured a request without a matching request handler: + .toHaveBeenCalledWith(`[MSW] Error: intercepted a request without a matching request handler: • GET ${endpointUrl} diff --git a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/warn.node.test.ts b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/warn.node.test.ts index 091864ce0..28fd440f9 100644 --- a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/warn.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/warn.node.test.ts @@ -26,7 +26,7 @@ test('warns on unhandled request when using the "warn" value', async () => { expect(res).toHaveProperty('status', 404) expect(console.warn).toBeCalledWith(`\ -[MSW] Warning: captured a request without a matching request handler: +[MSW] Warning: intercepted a request without a matching request handler: • GET https://test.mswjs.io/ diff --git a/test/node/msw-api/setup-server/scenarios/response-patching..node.test.ts b/test/node/msw-api/setup-server/scenarios/response-patching..node.test.ts index a2814b4ac..0c9891509 100644 --- a/test/node/msw-api/setup-server/scenarios/response-patching..node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/response-patching..node.test.ts @@ -21,8 +21,7 @@ interface ResponseBody { const server = setupServer( http.get('https://test.mswjs.io/user', async () => { - const fetchArgs = await bypass(httpServer.http.url('/user')) - const originalResponse = await fetch(...fetchArgs) + const originalResponse = await fetch(bypass(httpServer.http.url('/user'))) const body = await originalResponse.json() return HttpResponse.json({ @@ -34,13 +33,15 @@ const server = setupServer( const url = new URL(request.url) const shouldBypass = url.searchParams.get('bypass') === 'true' - const fetchArgs = await bypass( - new Request(httpServer.http.url('/user'), { - method: 'POST', - }), - ) const performRequest = shouldBypass - ? () => fetch(...fetchArgs).then((res) => res.json()) + ? () => + fetch( + bypass( + new Request(httpServer.http.url('/user'), { + method: 'POST', + }), + ), + ).then((res) => res.json()) : () => fetch('https://httpbin.org/post', { method: 'POST' }).then((res) => res.json(), diff --git a/test/node/regressions/many-request-handlers-jsdom.test.ts b/test/node/regressions/many-request-handlers-jsdom.test.ts new file mode 100644 index 000000000..c2c81c2a6 --- /dev/null +++ b/test/node/regressions/many-request-handlers-jsdom.test.ts @@ -0,0 +1,65 @@ +/** + * @jest-environment jsdom + * + * @note In JSDOM, the "AbortSignal" class is polyfilled instead of + * using the Node.js global. Because of that, its instances won't + * pass the instance check of "require('event').setMaxListeners" + * (that's based on the internal Node.js symbol), resulting in + * an exception. + * @see https://github.com/mswjs/msw/pull/1779 + */ +import { graphql, http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +// Create a large number of request handlers. +const restHandlers = new Array(100).fill(null).map((_, index) => { + return http.post( + `https://example.com/resource/${index}`, + async ({ request }) => { + const text = await request.text() + return HttpResponse.text(text + index.toString()) + }, + ) +}) + +const graphqlHanlers = new Array(100).fill(null).map((_, index) => { + return graphql.query(`Get${index}`, () => { + return HttpResponse.json({ data: { index } }) + }) +}) + +const server = setupServer(...restHandlers, ...graphqlHanlers) + +beforeAll(() => { + server.listen() + jest.spyOn(process.stderr, 'write') +}) + +afterAll(() => { + server.close() + jest.restoreAllMocks() +}) + +it('does not print a memory leak warning when having many request handlers', async () => { + const httpResponse = await fetch('https://example.com/resource/42', { + method: 'POST', + body: 'request-body-', + }).then((response) => response.text()) + + const graphqlResponse = await fetch('https://example.com', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: `query Get42 { index }`, + }), + }).then((response) => response.json()) + + // Must not print any memory leak warnings. + expect(process.stderr.write).not.toHaveBeenCalled() + + // Must return the mocked response. + expect(httpResponse).toBe('request-body-42') + expect(graphqlResponse).toEqual({ data: { index: 42 } }) +}) diff --git a/test/node/regressions/many-request-handlers.test.ts b/test/node/regressions/many-request-handlers.test.ts new file mode 100644 index 000000000..ecdd4d793 --- /dev/null +++ b/test/node/regressions/many-request-handlers.test.ts @@ -0,0 +1,58 @@ +/** + * @jest-environment node + */ +import { graphql, http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +// Create a large number of request handlers. +const restHandlers = new Array(100).fill(null).map((_, index) => { + return http.post( + `https://example.com/resource/${index}`, + async ({ request }) => { + const text = await request.text() + return HttpResponse.text(text + index.toString()) + }, + ) +}) + +const graphqlHanlers = new Array(100).fill(null).map((_, index) => { + return graphql.query(`Get${index}`, () => { + return HttpResponse.json({ data: { index } }) + }) +}) + +const server = setupServer(...restHandlers, ...graphqlHanlers) + +beforeAll(() => { + server.listen() + jest.spyOn(process.stderr, 'write') +}) + +afterAll(() => { + server.close() + jest.restoreAllMocks() +}) + +it('does not print a memory leak warning when having many request handlers', async () => { + const httpResponse = await fetch('https://example.com/resource/42', { + method: 'POST', + body: 'request-body-', + }).then((response) => response.text()) + + const graphqlResponse = await fetch('https://example.com', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: `query Get42 { index }`, + }), + }).then((response) => response.json()) + + // Must not print any memory leak warnings. + expect(process.stderr.write).not.toHaveBeenCalled() + + // Must return the mocked response. + expect(httpResponse).toBe('request-body-42') + expect(graphqlResponse).toEqual({ data: { index: 42 } }) +}) diff --git a/tsconfig.json b/tsconfig.json index e024d9f99..bbefafb04 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,13 @@ "declaration": true, "declarationDir": "lib/types", "noEmit": true, - "lib": ["es2017", "ESNext.AsyncIterable", "dom", "webworker"], + "lib": [ + "es2017", + "ESNext.AsyncIterable", + "dom", + "dom.iterable", + "webworker" + ], "baseUrl": "./src", "paths": { "~/core": ["./core"],