From 1033f651291f67a0ce509af965fdd0aa50fa7509 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 23 Oct 2023 09:45:04 +0200 Subject: [PATCH] feat!: adopt the global Fetch API (#1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Frederik Rabøl Co-authored-by: Christoph Fricke Co-authored-by: Piotr Co-authored-by: Kristján Oddsson Co-authored-by: thepassle Co-authored-by: Kevin Østerkilde Co-authored-by: Laryssa Rocha Co-authored-by: Matthew Costabile --- .github/workflows/ci.yml | 44 +- .github/workflows/compat.yml | 78 + .github/workflows/release.yml | 2 +- .nvmrc | 2 +- CONTRIBUTING.md | 42 +- MIGRATING.md | 659 +++++++ README.md | 61 +- browser/package.json | 5 + cli/init.js | 6 +- config/constants.js | 3 +- config/copyServiceWorker.ts | 17 +- config/plugins/esbuild/copyWorkerPlugin.ts | 80 + .../esbuild/forceEsmExtensionsPlugin.ts | 54 + .../esbuild/resolveCoreImportsPlugin.ts | 24 + config/plugins/esbuild/workerScriptPlugin.ts | 82 - config/replaceCoreImports.js | 21 + config/scripts/patch-ts.js | 29 + config/scripts/validate-esm.js | 247 +++ global.d.ts | 9 + jest.config.js | 3 + jest.setup.js | 30 +- native/package.json | 3 +- node/package.json | 3 +- package.json | 74 +- pnpm-lock.yaml | 1566 ++++++++++++----- src/browser/index.ts | 3 + src/{ => browser}/setupWorker/glossary.ts | 94 +- .../setupWorker/setupWorker.node.test.ts | 0 src/{ => browser}/setupWorker/setupWorker.ts | 119 +- .../start/createFallbackRequestListener.ts | 67 + .../setupWorker/start/createFallbackStart.ts | 0 .../start/createRequestListener.ts | 116 ++ .../start/createResponseListener.ts | 25 +- .../setupWorker/start/createStartHandler.ts | 10 +- .../start/utils/createMessageChannel.ts | 17 +- .../setupWorker/start/utils/enableMocking.ts | 2 +- .../start/utils/getWorkerByRegistration.ts | 0 .../start/utils/getWorkerInstance.ts | 14 +- .../start/utils/prepareStartHandler.test.ts | 0 .../start/utils/prepareStartHandler.ts | 4 +- .../start/utils/printStartMessage.test.ts | 0 .../start/utils/printStartMessage.ts | 2 +- .../start/utils/validateWorkerScope.ts | 2 +- .../setupWorker/stop/createFallbackStop.ts | 0 .../setupWorker/stop/createStop.ts | 2 +- .../stop/utils/printStopMessage.test.ts | 0 .../stop/utils/printStopMessage.ts | 2 +- src/browser/tsconfig.json | 7 + .../utils/deferNetworkRequestsUntil.test.ts | 2 +- .../utils/deferNetworkRequestsUntil.ts | 0 .../utils}/getAbsoluteWorkerUrl.test.ts | 0 .../utils}/getAbsoluteWorkerUrl.ts | 0 src/browser/utils/parseWorkerRequest.ts | 15 + src/browser/utils/pruneGetRequestBody.test.ts | 53 + src/browser/utils/pruneGetRequestBody.ts | 21 + .../utils}/requestIntegrityCheck.ts | 2 +- .../utils/supportsReadableStreamTransfer.ts | 17 + src/context/body.test.ts | 22 - src/context/body.ts | 19 - src/context/cookie.node.test.ts | 10 - src/context/cookie.test.ts | 39 - src/context/cookie.ts | 23 - src/context/data.test.ts | 64 - src/context/data.ts | 21 - src/context/delay.node.test.ts | 40 - src/context/delay.test.ts | 42 - src/context/delay.ts | 72 - src/context/errors.test.ts | 72 - src/context/errors.ts | 27 - src/context/extensions.test.ts | 70 - src/context/extensions.ts | 20 - src/context/fetch.test.ts | 36 - src/context/fetch.ts | 65 - src/context/field.test.ts | 117 -- src/context/field.ts | 60 - src/context/index.ts | 12 - src/context/json.test.ts | 31 - src/context/json.ts | 22 - src/context/set.test.ts | 34 - src/context/set.ts | 56 - src/context/status.test.ts | 17 - src/context/status.ts | 22 - src/context/text.test.ts | 12 - src/context/text.ts | 17 - src/context/xml.test.ts | 12 - src/context/xml.ts | 18 - src/core/HttpResponse.test.ts | 65 + src/core/HttpResponse.ts | 122 ++ src/{ => core}/SetupApi.ts | 28 +- src/core/bypass.test.ts | 47 + src/core/bypass.ts | 36 + src/core/delay.ts | 70 + src/{ => core}/graphql.test.ts | 2 +- src/core/graphql.ts | 138 ++ .../handlers/GraphQLHandler.test.ts | 196 ++- src/{ => core}/handlers/GraphQLHandler.ts | 164 +- src/core/handlers/HttpHandler.test.ts | 218 +++ src/core/handlers/HttpHandler.ts | 169 ++ src/core/handlers/RequestHandler.ts | 297 ++++ src/{rest.spec.ts => core/http.spec.ts} | 6 +- src/core/http.ts | 51 + src/core/index.ts | 55 + src/core/passthrough.test.ts | 13 + src/core/passthrough.ts | 23 + src/core/sharedOptions.ts | 66 + src/{ => core}/typeUtils.ts | 8 +- src/core/utils/HttpResponse/decorators.ts | 56 + src/core/utils/getResponse.ts | 55 + src/core/utils/handleRequest.test.ts | 344 ++++ src/{ => core}/utils/handleRequest.ts | 96 +- src/core/utils/internal/Disposable.ts | 9 + src/{ => core}/utils/internal/checkGlobals.ts | 0 src/{ => core}/utils/internal/devUtils.ts | 0 .../utils/internal/getCallFrame.test.ts | 56 +- src/{ => core}/utils/internal/getCallFrame.ts | 2 +- .../utils/internal/isIterable.test.ts | 0 src/{ => core}/utils/internal/isIterable.ts | 0 .../utils/internal/isObject.test.ts | 0 src/{ => core}/utils/internal/isObject.ts | 0 .../utils/internal/isStringEqual.test.ts | 0 .../utils/internal/isStringEqual.ts | 0 .../utils/internal/jsonParse.test.ts | 0 src/{ => core}/utils/internal/jsonParse.ts | 0 .../utils/internal/mergeRight.test.ts | 0 src/{ => core}/utils/internal/mergeRight.ts | 0 .../internal/parseGraphQLRequest.test.ts | 99 ++ .../utils/internal/parseGraphQLRequest.ts | 72 +- .../utils/internal/parseMultipartData.test.ts | 0 .../utils/internal/parseMultipartData.ts | 0 .../utils/internal/pipeEvents.test.ts | 6 +- src/{ => core}/utils/internal/pipeEvents.ts | 0 .../utils/internal/requestHandlerUtils.ts | 12 +- .../utils/internal/toReadonlyArray.test.ts | 0 .../utils/internal/toReadonlyArray.ts | 0 .../utils/internal/tryCatch.test.ts | 0 src/{ => core}/utils/internal/tryCatch.ts | 0 src/core/utils/internal/uuidv4.ts | 3 + .../utils/logging/getStatusCodeColor.test.ts | 0 .../utils/logging/getStatusCodeColor.ts | 0 .../utils/logging/getTimestamp.test.ts | 0 src/{ => core}/utils/logging/getTimestamp.ts | 0 .../utils/logging/serializeRequest.test.ts | 23 + src/core/utils/logging/serializeRequest.ts | 23 + .../utils/logging/serializeResponse.test.ts | 77 + src/core/utils/logging/serializeResponse.ts | 31 + .../utils/matching/matchRequestUrl.test.ts | 0 .../utils/matching/matchRequestUrl.ts | 2 +- .../utils/matching/normalizePath.node.test.ts | 0 .../utils/matching/normalizePath.test.ts | 0 .../utils/matching/normalizePath.ts | 0 .../request/getPublicUrlFromRequest.test.ts | 26 + .../utils/request/getPublicUrlFromRequest.ts | 15 + .../request/getRequestCookies.node.test.ts | 3 +- .../utils/request/getRequestCookies.test.ts | 11 +- src/core/utils/request/getRequestCookies.ts | 76 + .../utils/request/onUnhandledRequest.test.ts | 135 +- .../utils/request/onUnhandledRequest.ts | 76 +- .../utils/request/readResponseCookies.ts | 8 +- src/core/utils/toResponseInit.ts | 7 + src/{ => core}/utils/url/cleanUrl.test.ts | 0 src/{ => core}/utils/url/cleanUrl.ts | 0 .../utils/url/getAbsoluteUrl.node.test.ts | 0 .../utils/url/getAbsoluteUrl.test.ts | 0 src/{ => core}/utils/url/getAbsoluteUrl.ts | 0 .../utils/url/isAbsoluteUrl.test.ts | 0 src/{ => core}/utils/url/isAbsoluteUrl.ts | 0 src/graphql.ts | 110 -- src/handlers/RequestHandler.ts | 286 --- src/handlers/RestHandler.test.ts | 200 --- src/handlers/RestHandler.ts | 197 --- src/iife/index.ts | 2 + src/index.ts | 75 - src/mockServiceWorker.js | 171 +- src/native/index.ts | 7 +- src/node/SetupServerApi.ts | 144 +- src/node/glossary.ts | 50 +- src/node/index.ts | 3 +- src/node/setupServer.ts | 11 +- src/node/utils/isNodeException.ts | 10 + src/response.ts | 93 - src/rest.ts | 43 - .../start/createFallbackRequestListener.ts | 85 - .../start/createRequestListener.ts | 141 -- src/setupWorker/start/utils/streamResponse.ts | 56 - src/sharedOptions.ts | 30 - src/utils/NetworkError.ts | 6 - src/utils/getResponse.ts | 88 - src/utils/handleRequest.test.ts | 279 --- src/utils/internal/StrictBroadcastChannel.ts | 27 - src/utils/internal/compose.test.ts | 24 - src/utils/internal/compose.ts | 46 - .../internal/parseGraphQLRequest.test.ts | 86 - src/utils/internal/uuidv4.ts | 7 - src/utils/logging/prepareRequest.test.ts | 28 - src/utils/logging/prepareRequest.ts | 22 - src/utils/logging/prepareResponse.test.ts | 52 - src/utils/logging/prepareResponse.ts | 18 - src/utils/logging/serializeResponse.ts | 16 - src/utils/request/MockedRequest.test.ts | 237 --- src/utils/request/MockedRequest.ts | 180 -- .../createResponseFromIsomorphicResponse.ts | 11 - .../request/getPublicUrlFromRequest.test.ts | 19 - src/utils/request/getPublicUrlFromRequest.ts | 14 - src/utils/request/getRequestCookies.ts | 35 - src/utils/request/parseBody.test.ts | 114 -- src/utils/request/parseBody.ts | 33 - src/utils/request/parseWorkerRequest.ts | 21 - src/utils/request/pruneGetRequestBody.test.ts | 38 - src/utils/request/pruneGetRequestBody.ts | 21 - .../graphql-api/anonymous-operation.mocks.ts | 12 + .../graphql-api/anonymous-operation.test.ts | 210 +++ test/browser/graphql-api/cookies.mocks.ts | 21 +- test/browser/graphql-api/cookies.test.ts | 2 +- .../graphql-api/document-node.mocks.ts | 35 +- test/browser/graphql-api/errors.mocks.ts | 13 +- test/browser/graphql-api/extensions.mocks.ts | 25 +- test/browser/graphql-api/link.mocks.ts | 72 +- test/browser/graphql-api/link.test.ts | 4 +- test/browser/graphql-api/logging.mocks.ts | 39 +- test/browser/graphql-api/logging.test.ts | 10 +- .../graphql-api/multipart-data.mocks.ts | 65 +- .../graphql-api/multipart-data.test.ts | 22 +- test/browser/graphql-api/mutation.mocks.ts | 13 +- test/browser/graphql-api/mutation.test.ts | 2 +- .../graphql-api/operation-reference.mocks.ts | 31 +- .../graphql-api/operation-reference.test.ts | 6 +- test/browser/graphql-api/operation.mocks.ts | 17 +- test/browser/graphql-api/operation.test.ts | 14 +- test/browser/graphql-api/query.mocks.ts | 13 +- test/browser/graphql-api/query.test.ts | 2 +- .../graphql-api/response-patching.mocks.ts | 45 +- .../graphql-api/response-patching.test.ts | 32 +- test/browser/graphql-api/variables.mocks.ts | 41 +- .../async-response-transformer.mocks.ts | 41 - .../async-response-transformer.test.ts | 44 - test/browser/msw-api/context/delay.mocks.ts | 18 +- test/browser/msw-api/context/delay.test.ts | 9 +- .../msw-api/distribution/iife.mocks.js | 6 +- .../browser/msw-api/distribution/iife.test.ts | 1 - .../msw-api/exception-handling.mocks.ts | 7 +- .../msw-api/exception-handling.test.ts | 3 +- test/browser/msw-api/hard-reload.mocks.ts | 7 +- test/browser/msw-api/hard-reload.test.ts | 3 +- .../msw-api/integrity-check-invalid.mocks.ts | 9 +- .../msw-api/integrity-check-valid.mocks.ts | 7 +- test/browser/msw-api/integrity-check.test.ts | 6 +- .../msw-api/regression/handle-stream.mocks.ts | 8 +- .../msw-api/regression/null-body.mocks.ts | 7 +- .../msw-api/regression/null-body.test.ts | 8 +- test/browser/msw-api/req/passthrough.mocks.ts | 11 +- test/browser/msw-api/req/passthrough.test.ts | 39 +- .../msw-api/res/network-error.mocks.ts | 7 +- .../fallback-mode/fallback-mode.mocks.ts | 7 +- .../fallback-mode/fallback-mode.test.ts | 5 +- .../setup-worker/input-validation.mocks.ts | 7 +- .../life-cycle-events/on.mocks.ts | 45 +- .../setup-worker/life-cycle-events/on.test.ts | 11 +- .../removeAllListeners.test.ts | 2 +- .../life-cycle-events/removeListener.test.ts | 2 +- .../setup-worker/listHandlers.mocks.ts | 11 +- .../msw-api/setup-worker/listHandlers.test.ts | 11 +- .../setup-worker/printHandlers.mocks.ts | 23 - .../setup-worker/printHandlers.test.ts | 71 - .../setup-worker/resetHandlers.test.ts | 18 +- .../setup-worker/response-logging.test.ts | 8 +- .../setup-worker/restoreHandlers.test.ts | 27 +- .../scenarios/custom-transformers.mocks.ts | 23 +- .../scenarios/errors/internal-error.mocks.ts | 5 +- .../scenarios/errors/network-error.mocks.ts | 7 +- .../scenarios/errors/network-error.test.ts | 48 +- .../scenarios/fall-through.mocks.ts | 15 +- .../scenarios/iframe/iframe.mocks.ts | 7 +- .../scope/scope-nested-quiet.mocks.ts | 2 +- .../scenarios/scope/scope-nested.mocks.ts | 2 +- .../scenarios/scope/scope-root.mocks.ts | 2 +- .../shared-worker/shared-worker.mocks.ts | 2 +- .../scenarios/text-event-stream.mocks.ts | 7 +- .../msw-api/setup-worker/start/error.mocks.ts | 7 +- .../msw-api/setup-worker/start/error.test.ts | 2 +- .../start/find-worker.error.mocks.ts | 11 +- .../setup-worker/start/find-worker.mocks.ts | 13 +- .../setup-worker/start/find-worker.test.ts | 2 +- .../on-unhandled-request/bypass.mocks.ts | 7 +- .../callback-print.mocks.ts | 14 +- .../callback-print.test.ts | 4 +- .../callback-throws.mocks.ts | 11 +- .../on-unhandled-request/callback.mocks.ts | 11 +- .../on-unhandled-request/default.mocks.ts | 7 +- .../on-unhandled-request/default.test.ts | 2 +- .../start/on-unhandled-request/error.mocks.ts | 7 +- .../suggestions.graphql.test.ts | 27 +- .../on-unhandled-request/suggestions.mocks.ts | 5 +- .../suggestions.rest.test.ts | 35 +- .../start/on-unhandled-request/warn.mocks.ts | 11 +- .../start/on-unhandled-request/warn.test.ts | 8 +- .../start/options-sw-scope.mocks.ts | 7 +- .../msw-api/setup-worker/start/quiet.mocks.ts | 15 +- .../msw-api/setup-worker/start/quiet.test.ts | 8 +- .../msw-api/setup-worker/start/start.mocks.ts | 7 +- .../msw-api/setup-worker/start/start.test.ts | 2 +- .../start/wait-until-ready.error.mocks.ts | 11 +- .../start/wait-until-ready.false.mocks.ts | 11 +- .../start/wait-until-ready.mocks.ts | 11 +- .../msw-api/setup-worker/stop.mocks.ts | 7 +- .../browser/msw-api/setup-worker/stop.test.ts | 8 +- .../msw-api/setup-worker/stop/quiet.mocks.ts | 2 +- .../msw-api/setup-worker/stop/quiet.test.ts | 2 +- .../stop/removes-all-listeners.mocks.ts | 7 +- .../stop/removes-all-listeners.test.ts | 2 +- .../browser/msw-api/setup-worker/use.mocks.ts | 14 +- test/browser/msw-api/setup-worker/use.test.ts | 57 +- test/browser/msw-api/unregister.mocks.ts | 7 +- test/browser/msw-api/unregister.test.ts | 9 +- test/browser/playwright.extend.ts | 2 +- test/browser/rest-api/basic.mocks.ts | 17 +- test/browser/rest-api/basic.test.ts | 3 +- test/browser/rest-api/body.mocks.ts | 65 +- test/browser/rest-api/body.test.ts | 95 +- test/browser/rest-api/context.mocks.ts | 26 +- test/browser/rest-api/context.test.ts | 2 +- .../rest-api/cookies-inheritance.mocks.ts | 33 +- .../browser/rest-api/cookies-request.mocks.ts | 9 +- test/browser/rest-api/cookies-request.test.ts | 12 +- test/browser/rest-api/cookies.mocks.ts | 33 +- test/browser/rest-api/cookies.test.ts | 4 +- test/browser/rest-api/cors.mocks.ts | 2 +- .../rest-api/custom-request-handler.mocks.ts | 93 - .../rest-api/custom-request-handler.test.ts | 43 - test/browser/rest-api/generator.mocks.ts | 64 +- test/browser/rest-api/generator.test.ts | 4 +- .../rest-api/headers-multiple.mocks.ts | 31 +- .../browser/rest-api/headers-multiple.test.ts | 7 +- test/browser/rest-api/logging.test.ts | 6 +- test/browser/rest-api/params.mocks.ts | 24 +- test/browser/rest-api/params.test.ts | 3 +- test/browser/rest-api/plain-response.mocks.ts | 10 + test/browser/rest-api/plain-response.test.ts | 26 + .../rest-api/query-params-warning.mocks.ts | 15 +- test/browser/rest-api/query.mocks.ts | 25 +- test/browser/rest-api/query.test.ts | 6 +- test/browser/rest-api/redirect.mocks.ts | 24 +- test/browser/rest-api/redirect.test.ts | 5 +- .../request/body/body-form-data.page.html | 39 +- .../request/body/body-form-data.test.ts | 17 +- .../rest-api/request/body/body-json.test.ts | 4 +- .../rest-api/request/body/body.mocks.ts | 33 +- .../rest-api/request/matching/all.mocks.ts | 11 +- .../rest-api/request/matching/all.test.ts | 2 +- .../rest-api/request/matching/method.mocks.ts | 11 +- .../rest-api/request/matching/method.test.ts | 3 +- .../matching/path-params-decode.mocks.ts | 10 +- .../matching/path-params-decode.test.ts | 2 +- .../rest-api/request/matching/uri.mocks.ts | 46 +- .../rest-api/request/matching/uri.test.ts | 12 +- .../rest-api/response-patching.mocks.ts | 107 +- .../rest-api/response-patching.test.ts | 29 +- .../response/body/body-binary.mocks.ts | 15 +- .../response/body/body-binary.test.ts | 3 +- .../rest-api/response/body/body-blob.mocks.ts | 14 + .../rest-api/response/body/body-blob.test.ts | 13 + .../response/body/body-formdata.mocks.ts | 14 + .../response/body/body-formdata.test.ts | 18 + .../rest-api/response/body/body-json.mocks.ts | 11 +- .../response/body/body-stream.mocks.ts | 29 + .../response/body/body-stream.test.ts | 46 + .../rest-api/response/body/body-text.mocks.ts | 7 +- .../rest-api/response/body/body-xml.mocks.ts | 11 +- .../rest-api/response/response-error.mocks.ts | 10 + .../rest-api/response/response-error.test.ts | 27 + test/browser/rest-api/status.mocks.ts | 14 +- test/browser/rest-api/xhr.mocks.ts | 7 +- test/browser/setup/webpackHttpServer.ts | 4 +- test/jest.config.js | 18 +- test/modules/browser/esm-browser.test.ts | 87 + test/modules/browser/playwright.config.ts | 13 + test/modules/module-utils.ts | 52 + test/modules/node/esm-node.test.ts | 108 ++ test/modules/node/jest.config.js | 9 + .../graphql-api/anonymous-operations.test.ts | 108 ++ .../graphql-api/compatibility.node.test.ts | 14 +- test/node/graphql-api/cookies.node.test.ts | 20 +- test/node/graphql-api/extensions.node.test.ts | 22 +- .../response-patching.node.test.ts | 31 +- test/node/msw-api/context/delay.node.test.ts | 17 +- .../node/msw-api/req/passthrough.node.test.ts | 26 +- .../msw-api/res/network-error.node.test.ts | 19 +- .../input-validation.node.test.ts | 8 +- .../life-cycle-events/on.node.test.ts | 46 +- .../removeAllListeners.node.test.ts | 6 +- .../removeListener.node.test.ts | 6 +- .../setup-server/listHandlers.node.test.ts | 6 +- .../setup-server/printHandlers.node.test.ts | 96 - .../setup-server/resetHandlers.node.test.ts | 18 +- .../setup-server/restoreHandlers.node.test.ts | 16 +- .../scenarios/cookies-request.node.test.ts | 29 +- .../custom-transformers.node.test.ts | 20 +- .../scenarios/fake-timers.node.test.ts | 6 +- .../scenarios/fall-through.node.test.ts | 35 +- .../setup-server/scenarios/fetch.node.test.ts | 138 +- .../scenarios/generator.node.test.ts | 63 +- .../scenarios/graphql.node.test.ts | 26 +- .../setup-server/scenarios/http.node.test.ts | 26 +- .../setup-server/scenarios/https.node.test.ts | 28 +- .../on-unhandled-request/bypass.node.test.ts | 8 +- .../callback-throws.node.test.ts | 24 +- .../callback.node.test.ts | 8 +- .../on-unhandled-request/default.node.test.ts | 8 +- .../on-unhandled-request/error.node.test.ts | 12 +- .../on-unhandled-request/warn.node.test.ts | 8 +- .../scenarios/relative-url.node.test.ts | 11 +- .../scenarios/response-patching..node.test.ts | 85 +- .../setup-server/scenarios/xhr.node.test.ts | 20 +- .../msw-api/setup-server/use.node.test.ts | 63 +- .../many-request-handlers-jsdom.test.ts | 65 + .../regressions/many-request-handlers.test.ts | 58 + .../rest-api/cookies-inheritance.node.test.ts | 40 +- test/node/rest-api/https.node.test.ts | 46 + .../body/body-arraybuffer.node.test.ts | 15 +- .../request/body/body-form-data.node.test.ts | 24 +- .../request/body/body-json.node.test.ts | 38 +- .../request/body/body-text.node.test.ts | 9 +- .../request/body/body-used.node.test.ts | 66 + .../request/matching/all.node.test.ts | 22 +- .../matching/path-params-decode.node.test.ts | 15 +- .../{body => }/body-binary.node.test.ts | 21 +- .../rest-api/response/body-json.node.test.ts | 36 + .../response/body-stream.node.test.ts | 102 ++ .../{body => }/body-text.node.test.ts | 7 +- .../response/{body => }/body-xml.node.test.ts | 10 +- .../response/body/body-json.node.test.ts | 41 - .../rest-api/response/response-error.test.ts | 35 + test/support/graphql.ts | 10 +- test/typings/graphql.test-d.ts | 190 +- test/typings/path-params.test-d.ts | 55 - test/typings/rest.test-d.ts | 144 +- test/typings/run.ts | 2 +- test/typings/set.test-d.ts | 43 - test/typings/tsconfig.json | 6 +- tsconfig.json | 11 +- tsup.config.ts | 176 +- 440 files changed, 8971 insertions(+), 7709 deletions(-) create mode 100644 .github/workflows/compat.yml create mode 100644 MIGRATING.md create mode 100644 browser/package.json create mode 100644 config/plugins/esbuild/copyWorkerPlugin.ts create mode 100644 config/plugins/esbuild/forceEsmExtensionsPlugin.ts create mode 100644 config/plugins/esbuild/resolveCoreImportsPlugin.ts delete mode 100644 config/plugins/esbuild/workerScriptPlugin.ts create mode 100644 config/replaceCoreImports.js create mode 100644 config/scripts/patch-ts.js create mode 100644 config/scripts/validate-esm.js create mode 100644 src/browser/index.ts rename src/{ => browser}/setupWorker/glossary.ts (77%) rename src/{ => browser}/setupWorker/setupWorker.node.test.ts (100%) rename src/{ => browser}/setupWorker/setupWorker.ts (64%) create mode 100644 src/browser/setupWorker/start/createFallbackRequestListener.ts rename src/{ => browser}/setupWorker/start/createFallbackStart.ts (100%) create mode 100644 src/browser/setupWorker/start/createRequestListener.ts rename src/{ => browser}/setupWorker/start/createResponseListener.ts (62%) rename src/{ => browser}/setupWorker/start/createStartHandler.ts (94%) rename src/{ => browser}/setupWorker/start/utils/createMessageChannel.ts (62%) rename src/{ => browser}/setupWorker/start/utils/enableMocking.ts (94%) rename src/{ => browser}/setupWorker/start/utils/getWorkerByRegistration.ts (100%) rename src/{ => browser}/setupWorker/start/utils/getWorkerInstance.ts (88%) rename src/{ => browser}/setupWorker/start/utils/prepareStartHandler.test.ts (100%) rename src/{ => browser}/setupWorker/start/utils/prepareStartHandler.ts (90%) rename src/{ => browser}/setupWorker/start/utils/printStartMessage.test.ts (100%) rename src/{ => browser}/setupWorker/start/utils/printStartMessage.ts (93%) rename src/{ => browser}/setupWorker/start/utils/validateWorkerScope.ts (91%) rename src/{ => browser}/setupWorker/stop/createFallbackStop.ts (100%) rename src/{ => browser}/setupWorker/stop/createStop.ts (94%) rename src/{ => browser}/setupWorker/stop/utils/printStopMessage.test.ts (100%) rename src/{ => browser}/setupWorker/stop/utils/printStopMessage.ts (79%) create mode 100644 src/browser/tsconfig.json rename src/{ => browser}/utils/deferNetworkRequestsUntil.test.ts (94%) rename src/{ => browser}/utils/deferNetworkRequestsUntil.ts (100%) rename src/{utils/url => browser/utils}/getAbsoluteWorkerUrl.test.ts (100%) rename src/{utils/url => browser/utils}/getAbsoluteWorkerUrl.ts (100%) create mode 100644 src/browser/utils/parseWorkerRequest.ts create mode 100644 src/browser/utils/pruneGetRequestBody.test.ts create mode 100644 src/browser/utils/pruneGetRequestBody.ts rename src/{utils/internal => browser/utils}/requestIntegrityCheck.ts (90%) create mode 100644 src/browser/utils/supportsReadableStreamTransfer.ts delete mode 100644 src/context/body.test.ts delete mode 100644 src/context/body.ts delete mode 100644 src/context/cookie.node.test.ts delete mode 100644 src/context/cookie.test.ts delete mode 100644 src/context/cookie.ts delete mode 100644 src/context/data.test.ts delete mode 100644 src/context/data.ts delete mode 100644 src/context/delay.node.test.ts delete mode 100644 src/context/delay.test.ts delete mode 100644 src/context/delay.ts delete mode 100644 src/context/errors.test.ts delete mode 100644 src/context/errors.ts delete mode 100644 src/context/extensions.test.ts delete mode 100644 src/context/extensions.ts delete mode 100644 src/context/fetch.test.ts delete mode 100644 src/context/fetch.ts delete mode 100644 src/context/field.test.ts delete mode 100644 src/context/field.ts delete mode 100644 src/context/index.ts delete mode 100644 src/context/json.test.ts delete mode 100644 src/context/json.ts delete mode 100644 src/context/set.test.ts delete mode 100644 src/context/set.ts delete mode 100644 src/context/status.test.ts delete mode 100644 src/context/status.ts delete mode 100644 src/context/text.test.ts delete mode 100644 src/context/text.ts delete mode 100644 src/context/xml.test.ts delete mode 100644 src/context/xml.ts create mode 100644 src/core/HttpResponse.test.ts create mode 100644 src/core/HttpResponse.ts rename src/{ => core}/SetupApi.ts (84%) create mode 100644 src/core/bypass.test.ts create mode 100644 src/core/bypass.ts create mode 100644 src/core/delay.ts rename src/{ => core}/graphql.test.ts (100%) create mode 100644 src/core/graphql.ts rename src/{ => core}/handlers/GraphQLHandler.test.ts (72%) rename src/{ => core}/handlers/GraphQLHandler.ts (51%) create mode 100644 src/core/handlers/HttpHandler.test.ts create mode 100644 src/core/handlers/HttpHandler.ts create mode 100644 src/core/handlers/RequestHandler.ts rename src/{rest.spec.ts => core/http.spec.ts} (61%) create mode 100644 src/core/http.ts create mode 100644 src/core/index.ts create mode 100644 src/core/passthrough.test.ts create mode 100644 src/core/passthrough.ts create mode 100644 src/core/sharedOptions.ts rename src/{ => core}/typeUtils.ts (73%) create mode 100644 src/core/utils/HttpResponse/decorators.ts create mode 100644 src/core/utils/getResponse.ts create mode 100644 src/core/utils/handleRequest.test.ts rename src/{ => core}/utils/handleRequest.ts (50%) create mode 100644 src/core/utils/internal/Disposable.ts rename src/{ => core}/utils/internal/checkGlobals.ts (100%) rename src/{ => core}/utils/internal/devUtils.ts (100%) rename src/{ => core}/utils/internal/getCallFrame.test.ts (68%) rename src/{ => core}/utils/internal/getCallFrame.ts (91%) rename src/{ => core}/utils/internal/isIterable.test.ts (100%) rename src/{ => core}/utils/internal/isIterable.ts (100%) rename src/{ => core}/utils/internal/isObject.test.ts (100%) rename src/{ => core}/utils/internal/isObject.ts (100%) rename src/{ => core}/utils/internal/isStringEqual.test.ts (100%) rename src/{ => core}/utils/internal/isStringEqual.ts (100%) rename src/{ => core}/utils/internal/jsonParse.test.ts (100%) rename src/{ => core}/utils/internal/jsonParse.ts (100%) rename src/{ => core}/utils/internal/mergeRight.test.ts (100%) rename src/{ => core}/utils/internal/mergeRight.ts (100%) create mode 100644 src/core/utils/internal/parseGraphQLRequest.test.ts rename src/{ => core}/utils/internal/parseGraphQLRequest.ts (70%) rename src/{ => core}/utils/internal/parseMultipartData.test.ts (100%) rename src/{ => core}/utils/internal/parseMultipartData.ts (100%) rename src/{ => core}/utils/internal/pipeEvents.test.ts (74%) rename src/{ => core}/utils/internal/pipeEvents.ts (100%) rename src/{ => core}/utils/internal/requestHandlerUtils.ts (52%) rename src/{ => core}/utils/internal/toReadonlyArray.test.ts (100%) rename src/{ => core}/utils/internal/toReadonlyArray.ts (100%) rename src/{ => core}/utils/internal/tryCatch.test.ts (100%) rename src/{ => core}/utils/internal/tryCatch.ts (100%) create mode 100644 src/core/utils/internal/uuidv4.ts rename src/{ => core}/utils/logging/getStatusCodeColor.test.ts (100%) rename src/{ => core}/utils/logging/getStatusCodeColor.ts (100%) rename src/{ => core}/utils/logging/getTimestamp.test.ts (100%) rename src/{ => core}/utils/logging/getTimestamp.ts (100%) create mode 100644 src/core/utils/logging/serializeRequest.test.ts create mode 100644 src/core/utils/logging/serializeRequest.ts create mode 100644 src/core/utils/logging/serializeResponse.test.ts create mode 100644 src/core/utils/logging/serializeResponse.ts rename src/{ => core}/utils/matching/matchRequestUrl.test.ts (100%) rename src/{ => core}/utils/matching/matchRequestUrl.ts (96%) rename src/{ => core}/utils/matching/normalizePath.node.test.ts (100%) rename src/{ => core}/utils/matching/normalizePath.test.ts (100%) rename src/{ => core}/utils/matching/normalizePath.ts (100%) create mode 100644 src/core/utils/request/getPublicUrlFromRequest.test.ts create mode 100644 src/core/utils/request/getPublicUrlFromRequest.ts rename src/{ => core}/utils/request/getRequestCookies.node.test.ts (86%) rename src/{ => core}/utils/request/getRequestCookies.test.ts (77%) create mode 100644 src/core/utils/request/getRequestCookies.ts rename src/{ => core}/utils/request/onUnhandledRequest.test.ts (51%) rename src/{ => core}/utils/request/onUnhandledRequest.ts (77%) rename src/{ => core}/utils/request/readResponseCookies.ts (51%) create mode 100644 src/core/utils/toResponseInit.ts rename src/{ => core}/utils/url/cleanUrl.test.ts (100%) rename src/{ => core}/utils/url/cleanUrl.ts (100%) rename src/{ => core}/utils/url/getAbsoluteUrl.node.test.ts (100%) rename src/{ => core}/utils/url/getAbsoluteUrl.test.ts (100%) rename src/{ => core}/utils/url/getAbsoluteUrl.ts (100%) rename src/{ => core}/utils/url/isAbsoluteUrl.test.ts (100%) rename src/{ => core}/utils/url/isAbsoluteUrl.ts (100%) delete mode 100644 src/graphql.ts delete mode 100644 src/handlers/RequestHandler.ts delete mode 100644 src/handlers/RestHandler.test.ts delete mode 100644 src/handlers/RestHandler.ts create mode 100644 src/iife/index.ts delete mode 100644 src/index.ts create mode 100644 src/node/utils/isNodeException.ts delete mode 100644 src/response.ts delete mode 100644 src/rest.ts delete mode 100644 src/setupWorker/start/createFallbackRequestListener.ts delete mode 100644 src/setupWorker/start/createRequestListener.ts delete mode 100644 src/setupWorker/start/utils/streamResponse.ts delete mode 100644 src/sharedOptions.ts delete mode 100644 src/utils/NetworkError.ts delete mode 100644 src/utils/getResponse.ts delete mode 100644 src/utils/handleRequest.test.ts delete mode 100644 src/utils/internal/StrictBroadcastChannel.ts delete mode 100644 src/utils/internal/compose.test.ts delete mode 100644 src/utils/internal/compose.ts delete mode 100644 src/utils/internal/parseGraphQLRequest.test.ts delete mode 100644 src/utils/internal/uuidv4.ts delete mode 100644 src/utils/logging/prepareRequest.test.ts delete mode 100644 src/utils/logging/prepareRequest.ts delete mode 100644 src/utils/logging/prepareResponse.test.ts delete mode 100644 src/utils/logging/prepareResponse.ts delete mode 100644 src/utils/logging/serializeResponse.ts delete mode 100644 src/utils/request/MockedRequest.test.ts delete mode 100644 src/utils/request/MockedRequest.ts delete mode 100644 src/utils/request/createResponseFromIsomorphicResponse.ts delete mode 100644 src/utils/request/getPublicUrlFromRequest.test.ts delete mode 100644 src/utils/request/getPublicUrlFromRequest.ts delete mode 100644 src/utils/request/getRequestCookies.ts delete mode 100644 src/utils/request/parseBody.test.ts delete mode 100644 src/utils/request/parseBody.ts delete mode 100644 src/utils/request/parseWorkerRequest.ts delete mode 100644 src/utils/request/pruneGetRequestBody.test.ts delete mode 100644 src/utils/request/pruneGetRequestBody.ts create mode 100644 test/browser/graphql-api/anonymous-operation.mocks.ts create mode 100644 test/browser/graphql-api/anonymous-operation.test.ts delete mode 100644 test/browser/msw-api/context/async-response-transformer.mocks.ts delete mode 100644 test/browser/msw-api/context/async-response-transformer.test.ts delete mode 100644 test/browser/msw-api/setup-worker/printHandlers.mocks.ts delete mode 100644 test/browser/msw-api/setup-worker/printHandlers.test.ts delete mode 100644 test/browser/rest-api/custom-request-handler.mocks.ts delete mode 100644 test/browser/rest-api/custom-request-handler.test.ts create mode 100644 test/browser/rest-api/plain-response.mocks.ts create mode 100644 test/browser/rest-api/plain-response.test.ts create mode 100644 test/browser/rest-api/response/body/body-blob.mocks.ts create mode 100644 test/browser/rest-api/response/body/body-blob.test.ts create mode 100644 test/browser/rest-api/response/body/body-formdata.mocks.ts create mode 100644 test/browser/rest-api/response/body/body-formdata.test.ts create mode 100644 test/browser/rest-api/response/body/body-stream.mocks.ts create mode 100644 test/browser/rest-api/response/body/body-stream.test.ts create mode 100644 test/browser/rest-api/response/response-error.mocks.ts create mode 100644 test/browser/rest-api/response/response-error.test.ts create mode 100644 test/modules/browser/esm-browser.test.ts create mode 100644 test/modules/browser/playwright.config.ts create mode 100644 test/modules/module-utils.ts create mode 100644 test/modules/node/esm-node.test.ts create mode 100644 test/modules/node/jest.config.js create mode 100644 test/node/graphql-api/anonymous-operations.test.ts delete mode 100644 test/node/msw-api/setup-server/printHandlers.node.test.ts create mode 100644 test/node/regressions/many-request-handlers-jsdom.test.ts create mode 100644 test/node/regressions/many-request-handlers.test.ts create mode 100644 test/node/rest-api/https.node.test.ts create mode 100644 test/node/rest-api/request/body/body-used.node.test.ts rename test/node/rest-api/response/{body => }/body-binary.node.test.ts (75%) create mode 100644 test/node/rest-api/response/body-json.node.test.ts create mode 100644 test/node/rest-api/response/body-stream.node.test.ts rename test/node/rest-api/response/{body => }/body-text.node.test.ts (74%) rename test/node/rest-api/response/{body => }/body-xml.node.test.ts (83%) delete mode 100644 test/node/rest-api/response/body/body-json.node.test.ts create mode 100644 test/node/rest-api/response/response-error.test.ts delete mode 100644 test/typings/path-params.test-d.ts delete mode 100644 test/typings/set.test-d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46c36d33e..1029c106f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,17 +16,17 @@ jobs: - name: Checkout uses: actions/checkout@v3 - - name: Setup Node.js + - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 - uses: pnpm/action-setup@v2 with: version: 7.12 - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install - name: Unit tests run: pnpm test:unit @@ -46,41 +46,3 @@ jobs: with: name: playwright-report path: test/browser/test-results - - # Checks the library's compatibility with different - # TypeScript versions to discover type regressions. - typescript: - runs-on: macos-latest - # Skip TypeScript compatibility check on "main". - # A merged pull request implies passing "typescript" job. - if: github.ref != 'refs/heads/main' - strategy: - fail-fast: false - matrix: - ts: ['4.4', '4.5', '4.6', '4.7', '4.8', '4.9', '5.0', '5.1', '5.2'] - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: 16 - - - uses: pnpm/action-setup@v2 - with: - version: 7.12 - - - name: Install dependencies - run: pnpm install - - - name: Install TypeScript ${{ matrix.ts }} - run: pnpm add typescript@${{ matrix.ts }} - - - name: Build - run: pnpm build - - - name: Typings tests - run: | - pnpm tsc --version - pnpm test:ts diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml new file mode 100644 index 000000000..2cc378ed1 --- /dev/null +++ b/.github/workflows/compat.yml @@ -0,0 +1,78 @@ +name: compat + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + # Validate the package.json exports and emitted CJS/ESM bundles. + exports: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: Set up pnpm + uses: pnpm/action-setup@v2 + with: + version: 7.12 + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm build + + - name: Validate package.json exports + run: pnpm check:exports + + - name: Test modules (Node.js) + run: pnpm test:modules:node + + - name: Test modules (browser) + run: pnpm test:modules:browser + + # Checks the library's compatibility with different + # TypeScript versions to discover type regressions. + typescript: + runs-on: macos-latest + # Skip TypeScript compatibility check on "main". + # A merged pull request implies passing "typescript" job. + if: github.ref != 'refs/heads/main' + strategy: + fail-fast: false + matrix: + ts: ['4.7', '4.8', '4.9', '5.0', '5.1', '5.2'] + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + + - uses: pnpm/action-setup@v2 + with: + version: 7.12 + + - name: Install dependencies + run: pnpm install + + - name: Install TypeScript ${{ matrix.ts }} + run: pnpm add typescript@${{ matrix.ts }} + + - name: Build + run: pnpm build + + - name: Typings tests + run: | + pnpm tsc --version + pnpm test:ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b7796f38..540f3d59f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 18 always-auth: true registry-url: https://registry.npmjs.org diff --git a/.nvmrc b/.nvmrc index e2838c8b8..e8b25b544 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.14.0 \ No newline at end of file +v18.14.2 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1df787cf6..1d38a6d00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,19 +148,18 @@ Let's write an example integration test that asserts the interception of a GET r ```js // test/browser/example.mocks.ts -import { rest, setupWorker } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.get('/books', (req, res, ctx) => { - return res( - ctx.json([ - { - id: 'ea42ffcb-e729-4dd5-bfac-7a5b645cb1da', - title: 'The Lord of the Rings', - publishedAt: -486867600, - }, - ]), - ) + http.get('/books', () => { + return HttpResponse.json([ + { + id: 'ea42ffcb-e729-4dd5-bfac-7a5b645cb1da', + title: 'The Lord of the Rings', + publishedAt: -486867600, + }, + ]) }), ) @@ -217,24 +216,23 @@ Let's replicate the same `GET /books` integration test in Node.js. ```ts // test/node/example.test.ts import fetch from 'node-fetch' -import { rest } from 'msw' +import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' const server = setupServer( - rest.get('/books', (req, res, ctx) => { - return res( - ctx.json([ - { - id: 'ea42ffcb-e729-4dd5-bfac-7a5b645cb1da', - title: 'The Lord of the Rings', - publishedAt: -486867600, - }, - ]), - ) + http.get('/books', () => { + return HttpResponse.json([ + { + id: 'ea42ffcb-e729-4dd5-bfac-7a5b645cb1da', + title: 'The Lord of the Rings', + publishedAt: -486867600, + }, + ]) }), ) beforeAll(() => server.listen()) + afterAll(() => server.close()) test('returns a mocked response', async () => { diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 000000000..8ddc0e003 --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,659 @@ +# Migration guide + +This guide will help you migrate from the latest version of MSW to the `next` release that introduces a first-class support for Fetch API primitives to the library. **This is a breaking change**. In fact, this is the biggest change to our public API since the day the library was first published. Do not fret, however, as this is precisely why this document exists. + +## Getting started + +```sh +npm install msw@next --save-dev +``` + +## Table of contents + +To help you navigate, we've structured this guide on the feature basis. You can read it top-to-bottom, or you can jump to a particular feature you have trouble migrating from. + +- [**Imports**](#imports) +- [**Response resolver**](#response-resolver) (call signature change) +- [Request changes](#request-changes) +- [req.params](#reqparams) +- [req.cookies](#request-cookies) +- [req.passthrough](#reqpassthrough) +- [res.once](#resonce) +- [res.networkError](#resnetworkerror) +- [Context utilities](#context-utilities) + - [ctx.status](#ctxstatus) + - [ctx.set](#ctxset) + - [ctx.cookie](#ctxcookie) + - [ctx.body](#ctxbody) + - [ctx.text](#ctxtext) + - [ctx.json](#ctxjson) + - [ctx.xml](#ctxxml) + - [ctx.data](#ctxdata) + - [ctx.errors](#ctxerrors) + - [ctx.delay](#ctxdelay) + - [ctx.fetch](#ctx-fetch) +- [Life-cycle events](#life-cycle-events) +- [`.printHandlers()`](#print-handlers) +- [Advanced](#advanced) +- [**What's new in this release?**](#whats-new) +- [Common issues](#common-issues) + +--- + +## Imports + +### `rest` becomes `http` + +The `rest` request handler namespace has been renamed to `http`. + +```diff +-import { rest } from 'msw' ++import { http } from 'msw' +``` + +This affects the request handlers declaration as well: + +```js +import { http } from 'msw' + +export const handlers = [ + http.get('/resource', resolver), + http.post('/resource', resolver), + http.all('*', resolver), +] +``` + +### Browser imports + +The `setupWorker` API, alongside any related type definitions, are no longer exported from the root of `msw`. Instead, import them from `msw/browser`: + +```diff +-import { setupWorker } from 'msw' ++import { setupWorker } from 'msw/browser' +``` + +> Note that the request handlers like `http` and `graphql`, as well as the utility functions like `bypass` and `passthrough` must still be imported from the root-level `msw`. + +## Response resolver + +A response resolver now exposes a single object argument instead of `(req, res, ctx)`. That argument represents resolver information and consists of properties that are always present for all handler types and extra properties specific to handler types. + +### Resolver info + +#### General + +- `request`, a Fetch API `Request` instance representing an intercepted request. +- `cookies`, a parsed cookies object based on the request cookies. + +#### REST-specific + +- `params`, an object of parsed path parameters. + +#### GraphQL-specific + +- `query`, a GraphQL query string extracted from either URL search parameters or a POST request body. +- `variables`, an object of GraphQL query variables. + +### Using a new signature + +To mock responses, you should now return a Fetch API `Response` instance from the response resolver. You no longer need to compose a response via `res()`, and all the context utilities have also [been removed](#context-utilities). + +```js +http.get('/greet/:name', ({ request, params }) => { + console.log('Intercepted %s %s', request.method, request.url) + return new Response(`hello, ${params.name}!`) +}) +``` + +Now, a more complex example for both REST and GraphQL requests. + +```js +import { http, graphql } from 'msw' + +export const handlers = [ + http.put('/user/:id', async ({ request, params, cookies }) => { + // Read request body as you'd normally do with Fetch. + const payload = await request.json() + // Access path parameters like before. + const { id } = params + // Access cookies like before. + const { sessionId } = cookies + + return new Response(null, { status: 201 }) + }), + + graphql.mutation('CreateUser', ({ request, query, variables }) => { + return new Response( + JSON.stringify({ + data: { + user: { + id: 'abc-123', + firstName: variables.firstName, + }, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }), +] +``` + +### Request changes + +Since the returned `request` is now an instance of Fetch API `Request`, there are some changes to its properties. + +#### Request URL + +The `request.url` property is a string (previously, a `URL` instance). If you wish to operate with it like a `URL`, you need to construct it manually: + +```js +http.get('/product', ({ request }) => { + // For example, this is how you would access + // request search parameters now. + const url = new URL(request.url) + const productId = url.searchParams.get('id') +}) +``` + +#### `req.params` + +Path parameters are now exposed directly on the [Resolver info](#resolver-info) object (previously, `req.params`). + +```js +http.get('/resource', ({ params }) => { + console.log('Request path parameters:', params) +}) +``` + +#### `req.cookies` + +Request cookies are now exposed directly on the [Resolver info](#resolver-info) object (previously, `req.cookies`). + +```js +http.get('/resource', ({ cookies }) => { + console.log('Request cookies:', cookies) +}) +``` + +#### Request body + +The library now does no assumptions when reading the intercepted request's body (previously, `req.body`). Instead, you are in charge to read the request body as you see appropriate. + +> Note that since the intercepted request is now represented by a Fetch API `Request` instance, its `request.body` property still exists but returns a `ReadableStream`. + +For example, this is how you would read request body: + +```js +http.post('/resource', async ({ request }) => { + const data = await request.json() + // request.formData() / request.arrayBuffer() / etc. +}) +``` + +### Convenient response declarations + +Using the Fetch API `Response` instance may get quite verbose. To give you more convenient means of declaring mocked responses while remaining specification compliant and compatible, the library now exports an `HttpResponse` object. You can use that object to construct response instances faster. + +```js +import { http, HttpResponse } from 'msw' + +export const handlers = [ + http.get('/user', () => { + // This is synonymous to "ctx.json()": + // HttpResponse.json() stringifies the given body + // and sets the correct "Content-Type" response header + // to describe a JSON response body. + return HttpResponse.json({ firstName: 'John' }) + }), +] +``` + +> Read more on how to use `HttpResponse` to mock [REST API](#rest-response-body-utilities) and [GraphQL API](#graphql-response-body-utilities) responses. + +## Responses in Node.js + +Although MSW now respects the Fetch API specification, the older versions of Node.js do not, so you can't construct a `Response` instance because there is no such global class. + +To account for this, the library exports a `Response` class that you should use when declaring request handlers. Behind the hood, that response class is resolved to a compatible polyfill in Node.js; in the browser, it only aliases `global.Response` without introducing additional behaviors. + +```js +import { http,Response } from 'msw' + +setupServer( + http.get('/ping', () => { + return new Response('hello world) + }) +) +``` + +Relying on a single universal `Response` class will allow you to write request handlers that can run in both browser and Node.js environments. + +## `res.once` + +To create a one-time request handler, pass it an object as the third argument with `once: true` set: + +```js +import { HttpResponse, http } from 'msw' + +export const handlers = [ + http.get( + '/user', + () => { + return HttpResponse.text('hello') + }, + { once: true }, + ), +] +``` + +## `res.networkError` + +To respond to a request with a network error, use the `HttpResponse.error()` static method: + +```js +import { http, HttpResponse } from 'msw' + +export const handlers = [ + http.get('/resource', () => { + return HttpResponse.error() + }), +] +``` + +> Note that we are dropping support for custom network error messages to be more compliant with the standard [`Response.error()`](https://developer.mozilla.org/en-US/docs/Web/API/Response/error_static) network errors, which don't support custom error messages. + +## `req.passthrough` + +```js +import { http, passthrough } from 'msw' + +export const handlers = [ + http.get('/user', () => { + // Previously, "req.passthrough()". + return passthrough() + }), +] +``` + +--- + +## Context utilities + +Most of the context utilities you'd normally use via `ctx.*` were removed. Instead, we encourage you to set respective properties directly on the response instance: + +```js +import { HttpResponse, http } from 'msw' + +export const handlers = [ + http.post('/user', () => { + // ctx.json() + return HttpResponse.json( + { firstName: 'John' }, + { + status: 201, // ctx.status() + headers: { + 'X-Custom-Header': 'value', // ctx.set() + }, + }, + ) + }), +] +``` + +Let's go through each previously existing context utility and see how to declare its analogue using the `Response` class. + +### `ctx.status` + +```js +import { http, HttpResponse } from 'msw' + +export const handlers = [ + http.get('/resource', () => { + return HttpResponse.text('hello', { status: 201 }) + }), +] +``` + +### `ctx.set` + +```js +import { http, HttpResponse } from 'msw' + +export const handlers = [ + http.get('/resource', () => { + return HttpResponse.text('hello', { + headers: { + 'Content-Type': 'text/plain; charset=windows-1252', + }, + }) + }), +] +``` + +### `ctx.cookie` + +```js +import { HttpResponse } from 'msw' + +export const handlers = [ + http.get('/resource', () => { + return HttpResponse.text('hello', { + headers: { + 'Set-Cookie': 'token=abc-123', + }, + }) + }), +] +``` + +When you provide an object as the `ResponseInit.headers` value, you cannot specify multiple response cookies with the same name. Instead, to support multiple response cookies, provide a `Headers` instance: + +```js +import { HttpResponse, http } from 'msw' + +export const handlers = [ + http.get('/resource', () => { + return new HttpResponse(null, { + headers: new Headers([ + // Mock a multi-value response cookie header. + ['Set-Cookie', 'sessionId=123'], + ['Set-Cookie', 'gtm=en_US'], + ]), + }) + }), +] +``` + +> This is applicable to any multi-value headers, really. + +### `ctx.body` + +```js +import { http, HttpResponse } from 'msw' + +export const handlers = [ + http.get('/resource', () => { + return new HttpResponse('any-body') + }), +] +``` + +> Do not forget to set the `Content-Type` header that represents the mocked response's body type. If using common response body types, like text or json, see the respective migration instructions for those context utilities below. + +### `ctx.text` + +```js +import { http, HttpResponse } from 'msw' + +export const handlers = [ + http.get('/resource', () => { + return HttpResponse.text('hello') + }), +] +``` + +### `ctx.json` + +```js +import { http, HttpResponse } from 'msw' + +export const handlers = [ + http.get('/resource', () => { + return HttpResponse.json({ firstName: 'John' }) + }), +] +``` + +### `ctx.xml` + +```js +import { http, HttpResponse } from 'msw' + +export const handlers = [ + http.get('/resource', () => { + return HttpResponse.xml('') + }), +] +``` + +### `ctx.data` + +The `ctx.data` utility has been removed in favor of constructing a mocked JSON response with the "data" property in it. + +```js +import { HttpResponse } from 'msw' + +export const handlers = [ + http.get('/resource', () => { + return HttpResponse.json({ + data: { + user: { + firstName: 'John', + }, + }, + }) + }), +] +``` + +### `ctx.errors` + +The `ctx.errors` utility has been removed in favor of constructing a mocked JSON response with the "errors" property in it. + +```js +import { HttpResponse } from 'msw' + +export const handlers = [ + http.get('/resource', () => { + return HttpResponse.json({ + errors: [ + { + message: 'Something went wrong', + }, + ], + }) + }), +] +``` + +### `ctx.delay` + +```js +import { http, HttpResponse, delay } from 'msw' + +export const handlers = [ + http.get('/resource', async () => { + await delay() + return HttpResponse.text('hello') + }), +] +``` + +The `delay` function has the same call signature as the `ctx.delay` context function. This means it supports the delay mode as an argument: + +```js +await delay(500) +await delay('infinite') +``` + +### `ctx.fetch` + +The `ctx.fetch()` function has been removed in favor of the `bypass()` function. You should now always perform a regular `fetch()` call and wrap the request in the `bypass()` function if you wish for it to ignore any otherwise matching request handlers. + +```js +import { http, HttpResponse, bypass } from 'msw' + +export const handlers = [ + http.get('/resource', async ({ request }) => { + // Use the regular "fetch" from your environment. + const originalResponse = await fetch(bypass(request)) + const json = await originalResponse.json() + + // ...handle the original response, maybe return a mocked one. + }), +] +``` + +The `bypass()` function also accepts `RequestInit` as the second argument to modify the bypassed request. + +```js +// Bypass the given "request" and modify its headers. +bypass(request, { + headers: { + 'X-Modified-Header': 'true', + }, +}) +``` + +--- + +## Life-cycle events + +The life-cycle events listeners now accept a single argument being an object with contextual properties. + +```diff +-server.events.on('request:start', (request, requestId) = {}) ++server.events.on('request:start', ({ request, requestId}) => {}) +``` + +The request and response instances exposed in the life-cycle API have also been updated to return Fetch API `Request` and `Response` respectively. + +The request ID is now exposed as a standalone argument (previously, `req.id`). + +```js +server.events.on('request:start', ({ request, requestId }) => { + console.log(request.method, request.url) +}) +``` + +To read a request body, make sure to clone the request first. Otherwise, it won't be performed as it would be already read. + +```js +server.events.on('request:match', async ({ request }) => { + // Make sure to clone the request so it could be + // processed further down the line. + const clone = request.clone() + const json = await clone.json() + + console.log('Performed request with body:', json) +}) +``` + +The `response:*` events now always contain the response reference, the related request, and its id in the listener arguments. + +```js +worker.events.on('response:mocked', ({ response, request, requestId }) => { + console.log('response to %s %s is:', request.method, request.url, response) +}) +``` + +--- + +## `.printHandlers() + +The `worker.prinHandlers()` and `server.printHandlers()` methods were removed. Use the `.listHandlers()` method instead: + +```diff +-worker.printHandlers() ++console.log(worker.listHandlers()) +``` + +--- + +## Advanced + +It is still possible to create custom handlers and resolvers, just make sure to account for the new [resolver call signature](#response-resolver). + +### Custom response composition + +As this release removes the concept of response composition via `res()`, you can no longer compose context utilities or abstract their partial composed state to a helper function. + +Instead, you can abstract a common response logic into a plain function that creates a new `Response` or modifies a provided instance. + +```js +// utils.js +import { HttpResponse } from 'msw' + +export function augmentResponse(json) { + const response = HttpResponse.json(json, { + // Come up with some reusable defaults here. + }) + return response +} +``` + +```js +import { http } from 'msw' +import { augmentResponse } from './utils' + +export const handlers = [ + http.get('/user', () => { + return augmentResponse({ id: 1 }) + }), +] +``` + +--- + +## What's new? + +The main benefit of this release is the adoption of Fetch API primitives—`Request` and `Response` classes. By handling requests and responses as the platform does it, you bring your API mocking setup to the next level. Less library-specific abstractions, flatter learning curve, improved compatibility with other tools. But, most importantly, specification compliance and investment into a solution that uses standard APIs that are here to stay. + +### New request body methods + +You can now read the intercepted request body as you would a regular `Request` instance. This mainly means the addition of the following methods on the `request`: + +- `request.blob()` +- `request.formData()` +- `request.arrayBuffer()` + +For example, this is how you would read the request as `Blob`: + +```js +import { http } from 'msw' + +export const handlers = [ + http.get('/resource', async ({ request }) => { + const blob = await request.blob() + }), +] +``` + +### Support `ReadableStream` mocked responses + +You can now send a `ReadableStream` as the mocked response body. This is great for mocking any kind of streaming in HTTP responses. + +```js +import { http, HttpResponse, delay } from 'msw' + +http.get('/greeting', () => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('hello')) + await delay(100) + controller.enqueue(encoder.encode('world')) + await delay(100) + controller.close() + }, + }) + + return new HttpResponse(stream) +}) +``` + +--- + +## Common issues + +### `Response is not defined` + +This likely means that you are running an old version of Node.js. Please use Node.js v18.14.0 and higher with this version of MSW. Also, see [this](#responses-in-nodejs). + +### `multipart/form-data is not supported` in Node.js + +Earlier versions of Node.js 18, like v18.8.0, had no support for `request.formData()`. Please upgrade to the latest Node.js version where Undici have added the said support to resolve the issue. diff --git a/README.md b/README.md index 225664989..38edf3573 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +> [!IMPORTANT]\ +> **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). +

@@ -25,7 +28,7 @@ - **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 +41,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 +50,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:** @@ -71,17 +73,20 @@ In-browser usage is what sets Mock Service Worker apart from other tools. Utiliz ```js // src/mocks.js // 1. Import the library. -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' // 2. Describe network behavior with request handlers. const worker = setupWorker( - rest.get('https://github.com/octocat', (req, res, ctx) => { - return res( - ctx.delay(1500), - ctx.status(202, 'Mocked status'), - ctx.json({ - message: 'Mocked response JSON body', - }), + http.get('https://github.com/octocat', ({ request, params, cookies }) => { + return HttpResponse.json( + { + message: 'Mocked response', + }, + { + status: 202, + statusText: 'Mocked status', + }, ) }), ) @@ -98,7 +103,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? @@ -118,7 +123,7 @@ Take a look at the example of an integration test in Jest that uses [React Testi // test/Dashboard.test.js import React from 'react' -import { rest } from 'msw' +import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { render, screen, waitFor } from '@testing-library/react' import Dashboard from '../src/components/Dashboard' @@ -127,19 +132,17 @@ const server = setupServer( // Describe network behavior with request handlers. // Tip: move the handlers into their own module and // import it across your browser and Node.js setups! - rest.get('/posts', (req, res, ctx) => { - return res( - ctx.json([ - { - id: 'f8dd058f-9006-4174-8d49-e3086bc39c21', - title: `Avoid Nesting When You're Testing`, - }, - { - id: '8ac96078-6434-4959-80ed-cc834e7fef61', - title: `How I Built A Modern Website In 2021`, - }, - ]), - ) + http.get('/posts', ({ request, params, cookies }) => { + return HttpResponse.json([ + { + id: 'f8dd058f-9006-4174-8d49-e3086bc39c21', + title: `Avoid Nesting When You're Testing`, + }, + { + id: '8ac96078-6434-4959-80ed-cc834e7fef61', + title: `How I Built A Modern Website In 2021`, + }, + ]) }), ) @@ -174,7 +177,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. diff --git a/browser/package.json b/browser/package.json new file mode 100644 index 000000000..d29733135 --- /dev/null +++ b/browser/package.json @@ -0,0 +1,5 @@ +{ + "main": "../lib/browser/index.js", + "module": "../lib/browser/index.mjs", + "types": "../lib/browser/index.d.ts" +} diff --git a/cli/init.js b/cli/init.js index 16a0f312f..aed4f8600 100755 --- a/cli/init.js +++ b/cli/init.js @@ -21,14 +21,14 @@ module.exports = async function init(args) { if (!dirExists) { // Try to create the directory if it doesn't exist - const [createDirectoryError] = await until(() => + const createDirectoryResult = await until(() => fs.promises.mkdir(absolutePublicDir, { recursive: true }), ) invariant( - createDirectoryError == null, + createDirectoryResult.error == null, 'Failed to create a Service Worker at "%s": directory does not exist and could not be created.\nMake sure to include a relative path to the root directory of your server.\n\nSee the original error below:\n%s', absolutePublicDir, - createDirectoryError, + createDirectoryResult.error, ) } diff --git a/config/constants.js b/config/constants.js index e38940945..ba32bc9af 100644 --- a/config/constants.js +++ b/config/constants.js @@ -2,8 +2,7 @@ const path = require('path') const SERVICE_WORKER_SOURCE_PATH = path.resolve( __dirname, - '../', - 'src/mockServiceWorker.js', + '../src/mockServiceWorker.js', ) const SERVICE_WORKER_BUILD_PATH = path.resolve( diff --git a/config/copyServiceWorker.ts b/config/copyServiceWorker.ts index 264ed4da2..7058b36cb 100644 --- a/config/copyServiceWorker.ts +++ b/config/copyServiceWorker.ts @@ -1,10 +1,7 @@ import * as fs from 'fs' import * as path from 'path' -import chalk from 'chalk' import { until } from '@open-draft/until' -const { cyan } = chalk - /** * Copies the given Service Worker source file into the destination. * Injects the integrity checksum into the destination file. @@ -16,11 +13,11 @@ export default async function copyServiceWorker( ): Promise { console.log('Compiling Service Worker...') - const [readError, fileContent] = await until(() => + const readFileResult = await until(() => fs.promises.readFile(sourceFilePath, 'utf8'), ) - if (readError) { + if (readFileResult.error) { throw new Error('Failed to read file.\n${readError.message}') } @@ -36,17 +33,17 @@ export default async function copyServiceWorker( fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'), ) - const nextFileContent = fileContent + const nextFileContent = readFileResult.data .replace('', checksum) .replace('', packageJson.version) - const [writeFileError] = await until(() => + const writeFileResult = await until(() => fs.promises.writeFile(destFilePath, nextFileContent), ) - if (writeFileError) { - throw new Error(`Failed to write file.\n${writeFileError.message}`) + if (writeFileResult.error) { + throw new Error(`Failed to write file.\n${writeFileResult.error.message}`) } - console.log('Service Worker copied to: %s', cyan(destFilePath)) + console.log('Service Worker copied to: %s', destFilePath) } diff --git a/config/plugins/esbuild/copyWorkerPlugin.ts b/config/plugins/esbuild/copyWorkerPlugin.ts new file mode 100644 index 000000000..720671b0c --- /dev/null +++ b/config/plugins/esbuild/copyWorkerPlugin.ts @@ -0,0 +1,80 @@ +import fs from 'fs' +import path from 'path' +import crypto from 'crypto' +import minify from 'babel-minify' +import { invariant } from 'outvariant' +import type { Plugin } from 'esbuild' +import copyServiceWorker from '../../copyServiceWorker' + +const SERVICE_WORKER_ENTRY_PATH = path.resolve( + process.cwd(), + './src/mockServiceWorker.js', +) + +const SERVICE_WORKER_OUTPUT_PATH = path.resolve( + process.cwd(), + './lib/mockServiceWorker.js', +) + +function getChecksum(contents: string): string { + const { code } = minify( + contents, + {}, + { + // @ts-ignore "babel-minify" has no type definitions. + comments: false, + }, + ) + + return crypto.createHash('md5').update(code, 'utf8').digest('hex') +} + +export function getWorkerChecksum(): string { + const workerContents = fs.readFileSync(SERVICE_WORKER_ENTRY_PATH, 'utf8') + return getChecksum(workerContents) +} + +export function copyWorkerPlugin(checksum: string): Plugin { + return { + name: 'copyWorkerPlugin', + async setup(build) { + invariant( + SERVICE_WORKER_ENTRY_PATH, + 'Failed to locate the worker script source file', + ) + + if (fs.existsSync(SERVICE_WORKER_OUTPUT_PATH)) { + console.warn( + 'Skipped copying the worker script to "%s": already exists', + SERVICE_WORKER_OUTPUT_PATH, + ) + return + } + + // Generate the checksum from the worker script's contents. + // const workerContents = await fs.readFile(workerSourcePath, 'utf8') + // const checksum = getChecksum(workerContents) + + build.onLoad({ filter: /mockServiceWorker\.js$/ }, async () => { + return { + // Prevent the worker script from being transpiled. + // But, generally, the worker script is not in the entrypoints. + contents: '', + } + }) + + build.onEnd(() => { + console.log('worker script checksum:', checksum) + + // Copy the worker script on the next tick. + process.nextTick(async () => { + await copyServiceWorker( + SERVICE_WORKER_ENTRY_PATH, + SERVICE_WORKER_OUTPUT_PATH, + checksum, + ) + }) + }) + }, + } +} diff --git a/config/plugins/esbuild/forceEsmExtensionsPlugin.ts b/config/plugins/esbuild/forceEsmExtensionsPlugin.ts new file mode 100644 index 000000000..61e007409 --- /dev/null +++ b/config/plugins/esbuild/forceEsmExtensionsPlugin.ts @@ -0,0 +1,54 @@ +import { Plugin } from 'esbuild' + +export function forceEsmExtensionsPlugin(): Plugin { + return { + name: 'forceEsmExtensionsPlugin', + setup(build) { + const isEsm = build.initialOptions.format === 'esm' + + build.onEnd(async (result) => { + if (result.errors.length > 0) { + return + } + + for (const outputFile of result.outputFiles || []) { + const fileContents = outputFile.text + const nextFileContents = modifyRelativeImports(fileContents, isEsm) + + outputFile.contents = Buffer.from(nextFileContents) + } + }) + }, + } +} + +const CJS_RELATIVE_IMPORT_EXP = /require\(["'](\..+)["']\)(;)?/gm +const ESM_RELATIVE_IMPORT_EXP = /from ["'](\..+)["'](;)?/gm + +function modifyRelativeImports(contents: string, isEsm: boolean): string { + const extension = isEsm ? '.mjs' : '.js' + const importExpression = isEsm + ? ESM_RELATIVE_IMPORT_EXP + : CJS_RELATIVE_IMPORT_EXP + + return contents.replace( + importExpression, + (_, importPath, maybeSemicolon = '') => { + if (importPath.endsWith('.') || importPath.endsWith('/')) { + return isEsm + ? `from '${importPath}/index${extension}'${maybeSemicolon}` + : `require("${importPath}/index${extension}")${maybeSemicolon}` + } + + if (importPath.endsWith(extension)) { + return isEsm + ? `from '${importPath}'${maybeSemicolon}` + : `require("${importPath}")${maybeSemicolon}` + } + + return isEsm + ? `from '${importPath}${extension}'${maybeSemicolon}` + : `require("${importPath}${extension}")${maybeSemicolon}` + }, + ) +} diff --git a/config/plugins/esbuild/resolveCoreImportsPlugin.ts b/config/plugins/esbuild/resolveCoreImportsPlugin.ts new file mode 100644 index 000000000..1aa30ee8e --- /dev/null +++ b/config/plugins/esbuild/resolveCoreImportsPlugin.ts @@ -0,0 +1,24 @@ +import { Plugin } from 'esbuild' + +const { replaceCoreImports } = require('../../replaceCoreImports') + +export function resolveCoreImportsPlugin(): Plugin { + return { + name: 'resolveCoreImportsPlugin', + setup(build) { + build.onEnd(async (result) => { + if (result.errors.length > 0) { + return + } + + for (const outputFile of result.outputFiles || []) { + const isEsm = outputFile.path.endsWith('.mjs') + const fileContents = outputFile.text + const nextFileContents = replaceCoreImports(fileContents, isEsm) + + outputFile.contents = Buffer.from(nextFileContents) + } + }) + }, + } +} diff --git a/config/plugins/esbuild/workerScriptPlugin.ts b/config/plugins/esbuild/workerScriptPlugin.ts deleted file mode 100644 index f2c5885b7..000000000 --- a/config/plugins/esbuild/workerScriptPlugin.ts +++ /dev/null @@ -1,82 +0,0 @@ -import path from 'path' -import fs from 'fs-extra' -import crypto from 'crypto' -import minify from 'babel-minify' -import { invariant } from 'outvariant' -import type { Plugin } from 'esbuild' -import copyServiceWorker from '../../copyServiceWorker' - -function getChecksum(contents: string): string { - const { code } = minify( - contents, - {}, - { - // @ts-ignore "babel-minify" has no type definitions. - comments: false, - }, - ) - - return crypto.createHash('md5').update(code, 'utf8').digest('hex') -} - -let hasRunAlready = false - -export function workerScriptPlugin(): Plugin { - return { - name: 'workerScriptPlugin', - async setup(build) { - const workerSourcePath = path.resolve( - process.cwd(), - './src/mockServiceWorker.js', - ) - const workerOutputPath = path.resolve( - process.cwd(), - './lib/mockServiceWorker.js', - ) - - invariant( - workerSourcePath, - 'Failed to locate the worker script source file', - ) - invariant( - workerOutputPath, - 'Failed to locate the worker script output file', - ) - - // Generate the checksum from the worker script's contents. - const workerContents = await fs.readFile(workerSourcePath, 'utf8') - const checksum = getChecksum(workerContents) - - // Inject the global "SERVICE_WORKER_CHECKSUM" variable - // for runtime worker integrity check. - build.initialOptions.define = { - SERVICE_WORKER_CHECKSUM: JSON.stringify(checksum), - } - - // Prevent from copying the worker script multiple times. - // esbuild will execute this plugin for *each* format. - if (hasRunAlready) { - return - } - - hasRunAlready = true - - build.onLoad({ filter: /mockServiceWorker\.js$/ }, async () => { - return { - // Prevent the worker script from being transpiled. - // But, generally, the worker script is not in the entrypoints. - contents: '', - } - }) - - build.onEnd(() => { - console.log('worker script checksum:', checksum) - - // Copy the worker script on the next tick. - setTimeout(async () => { - await copyServiceWorker(workerSourcePath, workerOutputPath, checksum) - }, 100) - }) - }, - } -} diff --git a/config/replaceCoreImports.js b/config/replaceCoreImports.js new file mode 100644 index 000000000..3087485cf --- /dev/null +++ b/config/replaceCoreImports.js @@ -0,0 +1,21 @@ +function replaceCoreImports(fileContents, isEsm) { + const importPattern = isEsm + ? /from ["'](~\/core(.*))["'](;)?$/gm + : /require\(["'](~\/core(.*))["']\)(;)?/gm + + return fileContents.replace( + importPattern, + (_, __, maybeSubmodulePath, maybeSemicolon) => { + const submodulePath = maybeSubmodulePath || '/index' + const semicolon = maybeSemicolon || '' + + return isEsm + ? `from "../core${submodulePath}"${semicolon}` + : `require("../core${submodulePath}")${semicolon}` + }, + ) +} + +module.exports = { + replaceCoreImports, +} diff --git a/config/scripts/patch-ts.js b/config/scripts/patch-ts.js new file mode 100644 index 000000000..fa25c7895 --- /dev/null +++ b/config/scripts/patch-ts.js @@ -0,0 +1,29 @@ +const fs = require('fs') +const path = require('path') +const { replaceCoreImports } = require('../replaceCoreImports') + +async function patchTypeDefs() { + const typeDefsPaths = [ + path.resolve(__dirname, '../..', 'lib/browser/index.d.ts'), + path.resolve(__dirname, '../..', 'lib/node/index.d.ts'), + path.resolve(__dirname, '../..', 'lib/native/index.d.ts'), + ] + + for (const typeDefsPath of typeDefsPaths) { + if (!fs.existsSync(typeDefsPath)) { + continue + } + + const fileContents = fs.readFileSync(typeDefsPath, 'utf8') + + // Treat ".d.ts" files as ESM to replace "import" statements. + // Force no extension on the ".d.ts" imports. + const nextFileContents = replaceCoreImports(fileContents, true) + + fs.writeFileSync(typeDefsPath, nextFileContents, 'utf8') + + console.log('Successfully patched at "%s"!', typeDefsPath) + } +} + +patchTypeDefs() diff --git a/config/scripts/validate-esm.js b/config/scripts/validate-esm.js new file mode 100644 index 000000000..cc473820b --- /dev/null +++ b/config/scripts/validate-esm.js @@ -0,0 +1,247 @@ +const fs = require('fs') +const path = require('path') +const { invariant } = require('outvariant') + +const ROOT_PATH = path.resolve(__dirname, '../..') + +function fromRoot(...paths) { + return path.resolve(ROOT_PATH, ...paths) +} + +const PKG_JSON_PATH = fromRoot('package.json') +const PKG_JSON = require(PKG_JSON_PATH) + +function validatePackageExports() { + const { exports } = PKG_JSON + + // Validate the "main", "browser", and "types" root fields. + invariant('main' in PKG_JSON, 'Missing "main" field in package.json') + invariant('module' in PKG_JSON, 'Missing "module" field in package.json') + invariant('types' in PKG_JSON, 'Missing "types" field in package.json') + + invariant( + fs.existsSync(fromRoot(PKG_JSON.main)), + 'The "main" field points at a non-existing path at "%s"', + PKG_JSON.main, + ) + + // The "exports" key must be present. + invariant(exports, 'package.json must have an "exports" field') + + // The "exports" must list expected paths. + const expectedExportPaths = [ + '.', + './browser', + './node', + './package.json', + './native', + ] + expectedExportPaths.forEach((exportPath) => { + invariant(exportPath in exports, 'Missing exports path "%s"', exportPath) + }) + + // Must describe the root export properly. + const rootExport = exports['.'] + + validateExportConditions(`exports['.']`, rootExport) + validateBundle(rootExport.require, false) + validateBundle(rootExport.import, true) + validateTypeDefs(rootExport.types) + + // Validate "./browser" exports. + const browserExports = exports['./browser'] + validateExportConditions(`exports['./browser']`, browserExports) + invariant( + browserExports.node === null, + 'The "browser" export must set the "node" field to null', + ) + validateBundle(browserExports.require, false) + validateBundle(browserExports.import, true) + validateTypeDefs(browserExports.types) + + // Validate "./node" exports. + const nodeExports = exports['./node'] + validateExportConditions(`exports['./node']`, nodeExports) + invariant( + nodeExports.browser === null, + 'The "node" export must set the "browser" field to null', + ) + validateBundle(nodeExports.require, false) + validateBundle(nodeExports.import, true) + validateTypeDefs(nodeExports.types) + + // Validate "./native" exports. + const nativeExports = exports['./native'] + validateExportConditions(`exports['./native']`, nativeExports) + invariant( + nativeExports.browser === null, + 'The "native" export must set the "browser" field to null', + ) + validateBundle(nativeExports.require, false) + validateBundle(nativeExports.import, true) + validateTypeDefs(nativeExports.types) + + // Validate "./package.json" exports. + validateExportConditions( + `exports['./package.json]`, + exports['./package.json'], + ) + + console.log('✅ Validated package.json exports') +} + +function validateExportConditions(pointer, conditions) { + if (typeof conditions === 'string') { + invariant( + fs.existsSync(conditions), + 'Expected a valid path at "%s" but got %s', + pointer, + conditions, + ) + return + } + + const keys = Object.keys(conditions) + + if (conditions[keys[0]] !== null) { + invariant(keys[0] === 'types', 'FS') + } + + // Ensure that paths point to existing files. + keys.forEach((key) => { + const relativeExportPath = conditions[key] + + if (relativeExportPath === null) { + return + } + + const exportPath = fromRoot(relativeExportPath) + invariant( + fs.existsSync(exportPath), + 'Expected the path at "%s" ("%s") to point at existing file but got %s', + pointer, + key, + exportPath, + ) + }) +} + +const ESM_CORE_IMPORT_EXP = /from ["'](..\/)+core(.*)["'];?$/gm +const CJS_CORE_IMPORT_EXP = /require\(["'](..\/)+core(.*)["']\);?$/gm + +function getCodeSnippetAt(contents, index) { + return contents.slice(index - 100, index + 50) +} + +function validateBundle(bundlePath, isEsm = false) { + const expectedExtension = isEsm ? '.mjs' : '.js' + + invariant( + bundlePath.endsWith(expectedExtension), + 'Failed to validate bundle: provided bundle path does not point at an ".mjs" file: %s', + bundlePath, + ) + + const absoluteBundlePath = fromRoot(bundlePath) + const contents = fs.readFileSync(absoluteBundlePath, 'utf8') + + // The "~/core" imports must be overwritten on the bundler level. + invariant( + !contents.includes('~/core'), + 'Bundle at "%s" includes unresolved "~/core" imports:\n\n%s', + bundlePath, + getCodeSnippetAt(contents, contents.indexOf('~/core')), + ) + + // The "core" imports must end with the explicit ".mjs" extension. + const coreImportsMatches = + contents.matchAll(isEsm ? ESM_CORE_IMPORT_EXP : CJS_CORE_IMPORT_EXP) || [] + + for (const match of coreImportsMatches) { + const [, backslashes, relativeImportPath] = match + + invariant( + backslashes === '../', + 'Found a "core" import with incorrect nesting level', + ) + + invariant( + relativeImportPath !== '', + 'Found a "core" import without an explicit path at "%s":\n\n%s', + absoluteBundlePath, + getCodeSnippetAt(contents, match.index), + ) + + if (isEsm) { + // Ensure that all relative imports in the ESM bundle end with ".mjs". + // This way bundlers can distinguish between the referenced modules + // since the "core" directory contains both ".js" and ".mjs" modules on the same level. + invariant( + relativeImportPath.endsWith('.mjs'), + `Found a "core" import without "${expectedExtension}" extension at "%s":\n\n%s`, + absoluteBundlePath, + getCodeSnippetAt(contents, match.index), + ) + } + } + + console.log('✅ Validated bundle at "%s"', bundlePath) +} + +function validateTypeDefs(typeDefsPath) { + const absoluteTypeDefsPath = fromRoot(typeDefsPath) + invariant( + fs.existsSync(absoluteTypeDefsPath), + 'Failed to validate type definitions at "%s": file does not exist', + absoluteTypeDefsPath, + ) + + const contents = fs.readFileSync(absoluteTypeDefsPath, 'utf8') + + // The "~/core" imports must also be replaced with relative paths on build. + invariant( + !contents.includes('~/core'), + 'Found unresolved "~/core" imports at "%s":\n\n%s', + absoluteTypeDefsPath, + getCodeSnippetAt(contents, contents.indexOf('~/core')), + ) + + console.log('✅ Validated type definitions at "%s"', typeDefsPath) +} + +function validatePackageFiles() { + const { files } = PKG_JSON + + const expectedFiles = [ + 'config/constants.js', + 'config/scripts/postinstall.js', + 'cli', + 'lib', + 'browser', + 'node', + 'native', + ] + + // Must list all the expcted files. + expectedFiles.forEach((expectedFile) => { + invariant( + files.includes(expectedFile), + '"%s" is not listed in "files" in package.json', + expectedFile, + ) + }) + + // All the listed files must exist. + expectedFiles.every((expectedFile) => { + invariant( + fs.existsSync(fromRoot(expectedFile)), + 'The file "%s" in "files" points at non-existing file', + expectedFile, + ) + }) + + console.log('✅ Validated package.json "files" field') +} + +validatePackageExports() +validatePackageFiles() diff --git a/global.d.ts b/global.d.ts index 8976bba1f..e8970504b 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1 +1,10 @@ declare const SERVICE_WORKER_CHECKSUM: string + +declare module '@bundled-es-modules/cookie' { + export * as default from 'cookie' +} + +declare module '@bundled-es-modules/statuses' { + import * as statuses from 'statuses' + export default statuses +} diff --git a/jest.config.js b/jest.config.js index 68450831a..6feb897fd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,4 +7,7 @@ module.exports = { testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(j|t)sx?$', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], setupFiles: ['./jest.setup.js'], + moduleNameMapper: { + '^~/core(/.*)?$': '/src/core/$1', + }, } diff --git a/jest.setup.js b/jest.setup.js index 50eb416a3..318a499b0 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,13 +1,27 @@ -const fetch = require('node-fetch') +const { TextEncoder, TextDecoder } = require('util') -if (typeof window !== 'undefined') { - // Provide "Headers" to be accessible in test cases - // since they are not, by default. - Object.defineProperty(window, 'Headers', { - writable: true, - value: fetch.Headers, - }) +/** + * @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 }, +}) + +if (typeof window !== 'undefined') { Object.defineProperty(navigator || {}, 'serviceWorker', { writable: false, value: { diff --git a/native/package.json b/native/package.json index bb12dd1d1..a44c5d0e4 100644 --- a/native/package.json +++ b/native/package.json @@ -1,5 +1,6 @@ { + "browser": null, "main": "../lib/native/index.js", "module": "../lib/native/index.mjs", - "typings": "../lib/native/index.d.ts" + "types": "../lib/native/index.d.ts" } diff --git a/node/package.json b/node/package.json index 3d1591115..395e07575 100644 --- a/node/package.json +++ b/node/package.json @@ -1,5 +1,6 @@ { + "browser": null, "main": "../lib/node/index.js", "module": "../lib/node/index.mjs", - "typings": "../lib/node/index.d.ts" + "types": "../lib/node/index.d.ts" } diff --git a/package.json b/package.json index 20d9c42ba..8aec4edaa 100644 --- a/package.json +++ b/package.json @@ -2,39 +2,60 @@ "name": "msw", "version": "1.3.2", "description": "Seamless REST/GraphQL API mocking library for browser and Node.js.", - "main": "./lib/index.js", - "types": "./lib/index.d.ts", + "main": "./lib/core/index.js", + "module": "./lib/core/index.mjs", + "types": "./lib/core/index.d.ts", "packageManager": "pnpm@7.12.2", "exports": { ".": { - "default": "./lib/index.js" + "types": "./lib/core/index.d.ts", + "require": "./lib/core/index.js", + "import": "./lib/core/index.mjs", + "default": "./lib/core/index.js" }, - "./native": { - "types": "./lib/native/index.d.ts", - "default": "./lib/native/index.js" + "./browser": { + "node": null, + "types": "./lib/browser/index.d.ts", + "require": "./lib/browser/index.js", + "import": "./lib/browser/index.mjs", + "default": "./lib/browser/index.js" }, "./node": { + "browser": null, "types": "./lib/node/index.d.ts", "require": "./lib/node/index.js", + "import": "./lib/node/index.mjs", "default": "./lib/node/index.mjs" }, + "./native": { + "browser": null, + "types": "./lib/native/index.d.ts", + "require": "./lib/native/index.js", + "import": "./lib/native/index.mjs", + "default": "./lib/native/index.js" + }, "./package.json": "./package.json" }, "bin": { "msw": "cli/index.js" }, "engines": { - "node": ">=14" + "node": ">=18" }, "scripts": { "start": "tsup --watch", "clean": "rimraf ./lib", "lint": "eslint \"{cli,config,src,test}/**/*.ts\"", - "build": "cross-env NODE_ENV=production tsup", + "prebuild": "rimraf ./lib", + "build": "pnpm clean && cross-env NODE_ENV=production tsup && pnpm patch:dts", + "patch:dts": "node \"./config/scripts/patch-ts.js\"", + "check:exports": "node \"./config/scripts/validate-esm.js\"", "test": "pnpm test:unit && pnpm test:node && pnpm test:browser", "test:unit": "cross-env BABEL_ENV=test jest --maxWorkers=3", - "test:node": "jest --config=./test/jest.config.js", + "test:node": "jest --config=./test/jest.config.js --forceExit", "test:browser": "playwright test -c ./test/browser/playwright.config.ts", + "test:modules:node": "jest --config=./test/modules/node/jest.config.js", + "test:modules:browser": "playwright test -c ./test/modules/browser/playwright.config.ts", "test:ts": "ts-node test/typings/run.ts", "prepare": "pnpm simple-git-hooks init", "prepack": "pnpm build", @@ -68,8 +89,9 @@ "config/scripts/postinstall.js", "cli", "lib", - "native", + "browser", "node", + "native", "LICENSE.md", "README.md" ], @@ -88,23 +110,27 @@ ], "sideEffects": false, "dependencies": { - "@mswjs/cookies": "^0.2.2", - "@mswjs/interceptors": "^0.17.10", - "@open-draft/until": "^1.0.3", + "@bundled-es-modules/cookie": "^2.0.0", + "@bundled-es-modules/js-levenshtein": "^2.0.1", + "@bundled-es-modules/statuses": "^1.0.1", + "@mswjs/cookies": "^1.0.0", + "@mswjs/interceptors": "^0.25.1", + "@open-draft/until": "^2.1.0", "@types/cookie": "^0.4.1", "@types/js-levenshtein": "^1.1.1", - "chalk": "^4.1.1", + "@types/statuses": "^2.0.1", + "chalk": "^4.1.2", "chokidar": "^3.4.2", - "cookie": "^0.4.2", + "formdata-node": "4.4.1", "graphql": "^16.8.1", - "headers-polyfill": "3.2.5", + "headers-polyfill": "^4.0.1", "inquirer": "^8.2.0", "is-node-process": "^1.2.0", "js-levenshtein": "^1.1.6", "node-fetch": "^2.6.7", "outvariant": "^1.4.0", "path-to-regexp": "^6.2.0", - "strict-event-emitter": "^0.4.3", + "strict-event-emitter": "^0.5.0", "type-fest": "^2.19.0", "yargs": "^17.3.1" }, @@ -120,19 +146,22 @@ "@swc/jest": "^0.2.24", "@types/express": "^4.17.17", "@types/fs-extra": "^9.0.13", + "@types/glob": "^8.1.0", "@types/jest": "^29.4.0", "@types/json-bigint": "^1.0.1", - "@types/node": "^14.14.31", + "@types/node": "18.x", "@types/node-fetch": "^2.5.11", "@types/puppeteer": "^5.4.4", "@typescript-eslint/eslint-plugin": "^5.11.0", "@typescript-eslint/parser": "^5.11.0", + "@web/dev-server": "^0.1.38", "babel-loader": "^8.2.3", "babel-minify": "^0.5.1", "commitizen": "^4.2.4", "cross-env": "^7.0.3", "cross-fetch": "^3.1.5", "cz-conventional-changelog": "3.3.0", + "esbuild": "^0.17.15", "esbuild-loader": "^2.21.0", "eslint": "^7.30.0", "eslint-config-prettier": "^8.3.0", @@ -140,26 +169,27 @@ "express": "^4.18.2", "fs-extra": "^10.0.0", "fs-teardown": "^0.3.0", + "glob": "^9.3.4", "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", "json-bigint": "^1.0.0", "lint-staged": "^13.0.3", - "page-with": "^0.5.0", + "page-with": "^0.6.1", "prettier": "^2.7.1", "regenerator-runtime": "^0.13.9", "rimraf": "^3.0.2", "simple-git-hooks": "^2.8.0", - "statuses": "^2.0.0", "ts-node": "^10.9.1", - "tsup": "^5.12.8", + "tsup": "^6.7.0", "typescript": "^5.0.2", + "undici": "^5.20.0", "url-loader": "^4.1.1", "webpack": "^5.68.0", "webpack-dev-server": "^3.11.2", "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 05e02d57c..84896c459 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,12 +6,15 @@ overrides: specifiers: '@babel/core': ^7.17.2 '@babel/preset-env': ^7.16.11 + '@bundled-es-modules/cookie': ^2.0.0 + '@bundled-es-modules/js-levenshtein': ^2.0.1 + '@bundled-es-modules/statuses': ^1.0.1 '@commitlint/cli': ^16.1.0 '@commitlint/config-conventional': ^16.0.0 - '@mswjs/cookies': ^0.2.2 - '@mswjs/interceptors': ^0.17.10 + '@mswjs/cookies': ^1.0.0 + '@mswjs/interceptors': ^0.25.1 '@open-draft/test-server': ^0.4.2 - '@open-draft/until': ^1.0.3 + '@open-draft/until': ^2.1.0 '@ossjs/release': ^0.8.0 '@playwright/test': ^1.30.0 '@swc/core': ^1.3.35 @@ -19,32 +22,37 @@ specifiers: '@types/cookie': ^0.4.1 '@types/express': ^4.17.17 '@types/fs-extra': ^9.0.13 + '@types/glob': ^8.1.0 '@types/jest': ^29.4.0 '@types/js-levenshtein': ^1.1.1 '@types/json-bigint': ^1.0.1 - '@types/node': ^14.14.31 + '@types/node': 18.x '@types/node-fetch': ^2.5.11 '@types/puppeteer': ^5.4.4 + '@types/statuses': ^2.0.1 '@typescript-eslint/eslint-plugin': ^5.11.0 '@typescript-eslint/parser': ^5.11.0 + '@web/dev-server': ^0.1.38 babel-loader: ^8.2.3 babel-minify: ^0.5.1 - chalk: ^4.1.1 + chalk: ^4.1.2 chokidar: 3.4.1 commitizen: ^4.2.4 - cookie: ^0.4.2 cross-env: ^7.0.3 cross-fetch: ^3.1.5 cz-conventional-changelog: 3.3.0 + esbuild: ^0.17.15 esbuild-loader: ^2.21.0 eslint: ^7.30.0 eslint-config-prettier: ^8.3.0 eslint-plugin-prettier: ^3.4.0 express: ^4.18.2 + formdata-node: 4.4.1 fs-extra: ^10.0.0 fs-teardown: ^0.3.0 + glob: ^9.3.4 graphql: ^16.8.1 - headers-polyfill: 3.2.5 + headers-polyfill: ^4.0.1 inquirer: ^8.2.0 is-node-process: ^1.2.0 jest: ^29.4.3 @@ -54,18 +62,18 @@ specifiers: lint-staged: ^13.0.3 node-fetch: ^2.6.7 outvariant: ^1.4.0 - page-with: ^0.5.0 + page-with: ^0.6.1 path-to-regexp: ^6.2.0 prettier: ^2.7.1 regenerator-runtime: ^0.13.9 rimraf: ^3.0.2 simple-git-hooks: ^2.8.0 - statuses: ^2.0.0 - strict-event-emitter: ^0.4.3 + strict-event-emitter: ^0.5.0 ts-node: ^10.9.1 - tsup: ^5.12.8 + tsup: ^6.7.0 type-fest: ^2.19.0 typescript: ^5.0.2 + undici: ^5.20.0 url-loader: ^4.1.1 webpack: ^5.68.0 webpack-dev-server: ^3.11.2 @@ -73,23 +81,27 @@ specifiers: yargs: ^17.3.1 dependencies: - '@mswjs/cookies': 0.2.2 - '@mswjs/interceptors': 0.17.10 - '@open-draft/until': 1.0.3 + '@bundled-es-modules/cookie': 2.0.0 + '@bundled-es-modules/js-levenshtein': 2.0.1 + '@bundled-es-modules/statuses': 1.0.1 + '@mswjs/cookies': 1.0.0 + '@mswjs/interceptors': 0.25.1 + '@open-draft/until': 2.1.0 '@types/cookie': 0.4.1 '@types/js-levenshtein': 1.1.1 - chalk: 4.1.1 + '@types/statuses': 2.0.1 + chalk: 4.1.2 chokidar: 3.4.1 - cookie: 0.4.2 + formdata-node: 4.4.1 graphql: 16.8.1 - headers-polyfill: 3.2.5 + headers-polyfill: 4.0.1 inquirer: 8.2.5 is-node-process: 1.2.0 js-levenshtein: 1.1.6 node-fetch: 2.6.9 outvariant: 1.4.0 path-to-regexp: 6.2.1 - strict-event-emitter: 0.4.6 + strict-event-emitter: 0.5.0 type-fest: 2.19.0 yargs: 17.7.0 @@ -105,19 +117,22 @@ devDependencies: '@swc/jest': 0.2.24_@swc+core@1.3.35 '@types/express': 4.17.17 '@types/fs-extra': 9.0.13 + '@types/glob': 8.1.0 '@types/jest': 29.4.0 '@types/json-bigint': 1.0.1 - '@types/node': 14.18.36 + '@types/node': 18.17.14 '@types/node-fetch': 2.6.2 '@types/puppeteer': 5.4.7 '@typescript-eslint/eslint-plugin': 5.52.0_aaw67h7nkydj3qj4plp2jqjmxe '@typescript-eslint/parser': 5.52.0_jeuwjvsopuo23ctsjsevxmvjsi + '@web/dev-server': 0.1.38 babel-loader: 8.3.0_la66t7xldg4uecmyawueag5wkm babel-minify: 0.5.2 commitizen: 4.3.0_@swc+core@1.3.35 cross-env: 7.0.3 cross-fetch: 3.1.5 cz-conventional-changelog: 3.3.0_@swc+core@1.3.35 + esbuild: 0.17.19 esbuild-loader: 2.21.0_webpack@5.75.0 eslint: 7.32.0 eslint-config-prettier: 8.6.0_eslint@7.32.0 @@ -125,26 +140,35 @@ devDependencies: express: 4.18.2 fs-extra: 10.1.0 fs-teardown: 0.3.2 - jest: 29.4.3_nw6xvwuzmqp7vps7knduexkcvm + glob: 9.3.5 + jest: 29.4.3_v5qag4bu7yd4vl7sd6rt2doplm jest-environment-jsdom: 29.4.3 json-bigint: 1.0.0 lint-staged: 13.1.2 - page-with: 0.5.1_@swc+core@1.3.35 + page-with: 0.6.1_mtsvlg4x4u5udzh2pohivgt4x4 prettier: 2.8.4 regenerator-runtime: 0.13.11 rimraf: 3.0.2 simple-git-hooks: 2.8.1 - statuses: 2.0.1 - ts-node: 10.9.1_oe3jy5ze54sjippw2sqzxdlwem - tsup: 5.12.9_4s7jzcjqpdttwnwh3e3glkuq6y + ts-node: 10.9.1_x2vjra2lmmhd46xm3mchw7ztui + tsup: 6.7.0_4s7jzcjqpdttwnwh3e3glkuq6y typescript: 5.0.2 + undici: 5.23.0 url-loader: 4.1.1_webpack@5.75.0 - webpack: 5.75.0_@swc+core@1.3.35 + webpack: 5.75.0_mtsvlg4x4u5udzh2pohivgt4x4 webpack-dev-server: 3.11.3_webpack@5.75.0 - webpack-http-server: 0.5.0_@swc+core@1.3.35 + webpack-http-server: 0.5.0_mtsvlg4x4u5udzh2pohivgt4x4 packages: + /@75lb/deep-merge/1.1.1: + resolution: {integrity: sha512-xvgv6pkMGBA6GwdyJbNAnDmfAIR/DfWhrj9jgWh3TY7gRm3KO46x/GPjRg6wJ0nOepwqrNxFfojebh0Df4h4Tw==} + engines: {node: '>=12.17'} + dependencies: + lodash.assignwith: 4.2.0 + typical: 7.1.1 + dev: true + /@ampproject/remapping/2.2.0: resolution: {integrity: sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==} engines: {node: '>=6.0.0'} @@ -1346,6 +1370,24 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true + /@bundled-es-modules/cookie/2.0.0: + resolution: {integrity: sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==} + dependencies: + cookie: 0.5.0 + dev: false + + /@bundled-es-modules/js-levenshtein/2.0.1: + resolution: {integrity: sha512-DERMS3yfbAljKsQc0U2wcqGKUWpdFjwqWuoMugEJlqBnKO180/n+4SR/J8MRDt1AN48X1ovgoD9KrdVXcaa3Rg==} + dependencies: + js-levenshtein: 1.1.6 + dev: false + + /@bundled-es-modules/statuses/1.0.1: + resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + dependencies: + statuses: 2.0.1 + dev: false + /@commitlint/cli/16.3.0_@swc+core@1.3.35: resolution: {integrity: sha512-P+kvONlfsuTMnxSwWE1H+ZcPMY3STFaHb2kAacsqoIkNx66O0T7sTpBxpxkMrFPyhkJiLJnJWMhk4bbvYD3BMA==} engines: {node: '>=v12'} @@ -1413,7 +1455,7 @@ packages: engines: {node: '>=v12'} dependencies: '@commitlint/types': 16.2.1 - chalk: 4.1.1 + chalk: 4.1.2 dev: true /@commitlint/is-ignored/16.2.4: @@ -1442,10 +1484,10 @@ packages: '@commitlint/execute-rule': 16.2.1 '@commitlint/resolve-extends': 16.2.1 '@commitlint/types': 16.2.1 - '@types/node': 16.18.12 - chalk: 4.1.1 + '@types/node': 18.17.14 + chalk: 4.1.2 cosmiconfig: 7.1.0 - cosmiconfig-typescript-loader: 2.0.2_plaptv2cv5vvro2su5yxvauvda + cosmiconfig-typescript-loader: 2.0.2_f6calhiv3qbku3gmsoec3zvctu lodash: 4.17.21 resolve-from: 5.0.0 typescript: 4.9.5 @@ -1463,15 +1505,15 @@ packages: '@commitlint/execute-rule': 17.4.0 '@commitlint/resolve-extends': 17.4.4 '@commitlint/types': 17.4.4 - '@types/node': 16.18.12 - chalk: 4.1.1 + '@types/node': 18.17.14 + chalk: 4.1.2 cosmiconfig: 8.0.0 - cosmiconfig-typescript-loader: 4.3.0_gg653o4cxizhrslchmhiad54ma + cosmiconfig-typescript-loader: 4.3.0_bmci5xihqld5vqu3v4tyk3r5ra lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 resolve-from: 5.0.0 - ts-node: 10.9.1_plaptv2cv5vvro2su5yxvauvda + ts-node: 10.9.1_f6calhiv3qbku3gmsoec3zvctu typescript: 4.9.5 transitivePeerDependencies: - '@swc/core' @@ -1555,14 +1597,14 @@ packages: resolution: {integrity: sha512-7/z7pA7BM0i8XvMSBynO7xsB3mVQPUZbVn6zMIlp/a091XJ3qAXRXc+HwLYhiIdzzS5fuxxNIHZMGHVD4HJxdA==} engines: {node: '>=v12'} dependencies: - chalk: 4.1.1 + chalk: 4.1.2 dev: true /@commitlint/types/17.4.4: resolution: {integrity: sha512-amRN8tRLYOsxRr6mTnGGGvB5EmW/4DDjLMgiwK3CCVEmN6Sr/6xePGEpWaspKkckILuUORCwe6VfDBw6uj4axQ==} engines: {node: '>=v14'} dependencies: - chalk: 4.1.1 + chalk: 4.1.2 dev: true optional: true @@ -1582,6 +1624,15 @@ packages: dev: true optional: true + /@esbuild/android-arm/0.17.19: + resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-arm64/0.16.17: resolution: {integrity: sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==} engines: {node: '>=12'} @@ -1591,6 +1642,15 @@ packages: dev: true optional: true + /@esbuild/android-arm64/0.17.19: + resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-x64/0.16.17: resolution: {integrity: sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==} engines: {node: '>=12'} @@ -1600,6 +1660,15 @@ packages: dev: true optional: true + /@esbuild/android-x64/0.17.19: + resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/darwin-arm64/0.16.17: resolution: {integrity: sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w==} engines: {node: '>=12'} @@ -1609,6 +1678,15 @@ packages: dev: true optional: true + /@esbuild/darwin-arm64/0.17.19: + resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@esbuild/darwin-x64/0.16.17: resolution: {integrity: sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==} engines: {node: '>=12'} @@ -1618,6 +1696,15 @@ packages: dev: true optional: true + /@esbuild/darwin-x64/0.17.19: + resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@esbuild/freebsd-arm64/0.16.17: resolution: {integrity: sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==} engines: {node: '>=12'} @@ -1627,6 +1714,15 @@ packages: dev: true optional: true + /@esbuild/freebsd-arm64/0.17.19: + resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/freebsd-x64/0.16.17: resolution: {integrity: sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==} engines: {node: '>=12'} @@ -1636,6 +1732,15 @@ packages: dev: true optional: true + /@esbuild/freebsd-x64/0.17.19: + resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-arm/0.16.17: resolution: {integrity: sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==} engines: {node: '>=12'} @@ -1645,6 +1750,15 @@ packages: dev: true optional: true + /@esbuild/linux-arm/0.17.19: + resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-arm64/0.16.17: resolution: {integrity: sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==} engines: {node: '>=12'} @@ -1654,6 +1768,15 @@ packages: dev: true optional: true + /@esbuild/linux-arm64/0.17.19: + resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-ia32/0.16.17: resolution: {integrity: sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==} engines: {node: '>=12'} @@ -1663,10 +1786,10 @@ packages: dev: true optional: true - /@esbuild/linux-loong64/0.14.54: - resolution: {integrity: sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==} + /@esbuild/linux-ia32/0.17.19: + resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} engines: {node: '>=12'} - cpu: [loong64] + cpu: [ia32] os: [linux] requiresBuild: true dev: true @@ -1681,6 +1804,15 @@ packages: dev: true optional: true + /@esbuild/linux-loong64/0.17.19: + resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-mips64el/0.16.17: resolution: {integrity: sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==} engines: {node: '>=12'} @@ -1690,6 +1822,15 @@ packages: dev: true optional: true + /@esbuild/linux-mips64el/0.17.19: + resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-ppc64/0.16.17: resolution: {integrity: sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==} engines: {node: '>=12'} @@ -1699,6 +1840,15 @@ packages: dev: true optional: true + /@esbuild/linux-ppc64/0.17.19: + resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-riscv64/0.16.17: resolution: {integrity: sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==} engines: {node: '>=12'} @@ -1708,6 +1858,15 @@ packages: dev: true optional: true + /@esbuild/linux-riscv64/0.17.19: + resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-s390x/0.16.17: resolution: {integrity: sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==} engines: {node: '>=12'} @@ -1717,6 +1876,15 @@ packages: dev: true optional: true + /@esbuild/linux-s390x/0.17.19: + resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-x64/0.16.17: resolution: {integrity: sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==} engines: {node: '>=12'} @@ -1726,6 +1894,15 @@ packages: dev: true optional: true + /@esbuild/linux-x64/0.17.19: + resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/netbsd-x64/0.16.17: resolution: {integrity: sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==} engines: {node: '>=12'} @@ -1735,6 +1912,15 @@ packages: dev: true optional: true + /@esbuild/netbsd-x64/0.17.19: + resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/openbsd-x64/0.16.17: resolution: {integrity: sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==} engines: {node: '>=12'} @@ -1744,6 +1930,15 @@ packages: dev: true optional: true + /@esbuild/openbsd-x64/0.17.19: + resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/sunos-x64/0.16.17: resolution: {integrity: sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==} engines: {node: '>=12'} @@ -1753,6 +1948,15 @@ packages: dev: true optional: true + /@esbuild/sunos-x64/0.17.19: + resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-arm64/0.16.17: resolution: {integrity: sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==} engines: {node: '>=12'} @@ -1762,6 +1966,15 @@ packages: dev: true optional: true + /@esbuild/win32-arm64/0.17.19: + resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-ia32/0.16.17: resolution: {integrity: sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==} engines: {node: '>=12'} @@ -1771,6 +1984,15 @@ packages: dev: true optional: true + /@esbuild/win32-ia32/0.17.19: + resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-x64/0.16.17: resolution: {integrity: sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==} engines: {node: '>=12'} @@ -1780,6 +2002,15 @@ packages: dev: true optional: true + /@esbuild/win32-x64/0.17.19: + resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@eslint/eslintrc/0.4.3: resolution: {integrity: sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==} engines: {node: ^10.12.0 || >=12.0.0} @@ -1833,8 +2064,8 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.4.3 - '@types/node': 16.18.12 - chalk: 4.1.1 + '@types/node': 18.17.14 + chalk: 4.1.2 jest-message-util: 29.4.3 jest-util: 29.4.3 slash: 3.0.0 @@ -1854,14 +2085,14 @@ packages: '@jest/test-result': 29.4.3 '@jest/transform': 29.4.3 '@jest/types': 29.4.3 - '@types/node': 16.18.12 + '@types/node': 18.17.14 ansi-escapes: 4.3.2 - chalk: 4.1.1 + chalk: 4.1.2 ci-info: 3.8.0 exit: 0.1.2 graceful-fs: 4.2.10 jest-changed-files: 29.4.3 - jest-config: 29.4.3_ghv2zugsw3zjg5rog5rhyka5ja + jest-config: 29.4.3_v5qag4bu7yd4vl7sd6rt2doplm jest-haste-map: 29.4.3 jest-message-util: 29.4.3 jest-regex-util: 29.4.3 @@ -1895,7 +2126,7 @@ packages: dependencies: '@jest/fake-timers': 29.4.3 '@jest/types': 29.4.3 - '@types/node': 16.18.12 + '@types/node': 18.17.14 jest-mock: 29.4.3 dev: true @@ -1922,7 +2153,7 @@ packages: dependencies: '@jest/types': 29.4.3 '@sinonjs/fake-timers': 10.0.2 - '@types/node': 16.18.12 + '@types/node': 18.17.14 jest-message-util: 29.4.3 jest-mock: 29.4.3 jest-util: 29.4.3 @@ -1955,8 +2186,8 @@ packages: '@jest/transform': 29.4.3 '@jest/types': 29.4.3 '@jridgewell/trace-mapping': 0.3.17 - '@types/node': 16.18.12 - chalk: 4.1.1 + '@types/node': 18.17.14 + chalk: 4.1.2 collect-v8-coverage: 1.0.1 exit: 0.1.2 glob: 7.2.3 @@ -2021,7 +2252,7 @@ packages: '@jest/types': 29.4.3 '@jridgewell/trace-mapping': 0.3.17 babel-plugin-istanbul: 6.1.1 - chalk: 4.1.1 + chalk: 4.1.2 convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.10 @@ -2042,9 +2273,9 @@ packages: dependencies: '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 - '@types/node': 16.18.12 + '@types/node': 18.17.14 '@types/yargs': 16.0.5 - chalk: 4.1.1 + chalk: 4.1.2 dev: true /@jest/types/29.4.3: @@ -2054,9 +2285,9 @@ packages: '@jest/schemas': 29.4.3 '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 - '@types/node': 16.18.12 + '@types/node': 18.17.14 '@types/yargs': 17.0.22 - chalk: 4.1.1 + chalk: 4.1.2 dev: true /@jridgewell/gen-mapping/0.1.1: @@ -2111,28 +2342,21 @@ packages: '@jridgewell/sourcemap-codec': 1.4.14 dev: true - /@mswjs/cookies/0.2.2: - resolution: {integrity: sha512-mlN83YSrcFgk7Dm1Mys40DLssI1KdJji2CMKN8eOlBqsTADYzj2+jWzsANsUTFbxDMWPD5e9bfA1RGqBpS3O1g==} + /@mswjs/cookies/1.0.0: + resolution: {integrity: sha512-TdXoBdI+h/EDTsVLCX/34s4+9U0sWi92qFnIGUEikpMCSKLhBeujovyYVSoORNbYgsBH5ga7/tfxyWcEZAxiYA==} engines: {node: '>=14'} - dependencies: - '@types/set-cookie-parser': 2.4.2 - set-cookie-parser: 2.5.1 dev: false - /@mswjs/interceptors/0.17.10: - resolution: {integrity: sha512-N8x7eSLGcmUFNWZRxT1vsHvypzIRgQYdG0rJey/rZCy6zT/30qDt8Joj7FxzGNLSwXbeZqJOMqDurp7ra4hgbw==} - engines: {node: '>=14'} + /@mswjs/interceptors/0.25.1: + resolution: {integrity: sha512-iM/2Qp+y7zKrX1sf45sPvvE7CGly8AKSR8Ua7cXAszXCK/To5i/L8AwiheEaBSVcZ6R7Em7kTcyZWN5H2ivcEQ==} + engines: {node: '>=18'} dependencies: - '@open-draft/until': 1.0.3 - '@types/debug': 4.1.7 - '@xmldom/xmldom': 0.8.6 - debug: 4.3.4 - headers-polyfill: 3.2.5 + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 outvariant: 1.4.0 - strict-event-emitter: 0.2.8 - web-encoding: 1.1.5 - transitivePeerDependencies: - - supports-color + strict-event-emitter: 0.5.0 dev: false /@nodelib/fs.scandir/2.1.5: @@ -2156,9 +2380,15 @@ packages: fastq: 1.15.0 dev: true - /@open-draft/deferred-promise/2.1.0: - resolution: {integrity: sha512-Rzd5JrXZX8zErHzgcGyngh4fmEbSHqTETdGj9rXtejlqMIgXFlyKBA7Jn1Xp0Ls0M0Y22+xHcWiEzbmdWl0BOA==} - dev: true + /@open-draft/deferred-promise/2.2.0: + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + /@open-draft/logger/0.3.0: + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.0 + dev: false /@open-draft/test-server/0.4.2: resolution: {integrity: sha512-J9wbdQkPx5WKcDNtgfnXsx5ew4UJd6BZyGr89YlHeaUkOShkO2iO5QIyCCsG4qpjIvr2ZTkEYJA9ujOXXyO6Pg==} @@ -2176,18 +2406,14 @@ packages: - utf-8-validate dev: true - /@open-draft/until/1.0.3: - resolution: {integrity: sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==} - /@open-draft/until/2.1.0: resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - dev: true /@ossjs/release/0.8.0: resolution: {integrity: sha512-vzxhYvad/Ub3j8bWWCRfdwTvFzK3HtKjm8IM5J+7njnQcZZie5iouUXX+G65OI3F1YgQSWvsozrWqHyN1x7fjQ==} hasBin: true dependencies: - '@open-draft/deferred-promise': 2.1.0 + '@open-draft/deferred-promise': 2.2.0 '@open-draft/until': 2.1.0 '@types/conventional-commits-parser': 3.0.3 '@types/issue-parser': 3.0.1 @@ -2218,10 +2444,37 @@ packages: engines: {node: '>=14'} hasBin: true dependencies: - '@types/node': 16.18.12 + '@types/node': 18.17.14 playwright-core: 1.30.0 dev: true + /@rollup/plugin-node-resolve/13.3.0_rollup@2.79.1: + resolution: {integrity: sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw==} + engines: {node: '>= 10.0.0'} + peerDependencies: + rollup: ^2.42.0 + dependencies: + '@rollup/pluginutils': 3.1.0_rollup@2.79.1 + '@types/resolve': 1.17.1 + deepmerge: 4.3.0 + is-builtin-module: 3.2.1 + is-module: 1.0.0 + resolve: 1.22.1 + rollup: 2.79.1 + dev: true + + /@rollup/pluginutils/3.1.0_rollup@2.79.1: + resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==} + engines: {node: '>= 8.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0 + dependencies: + '@types/estree': 0.0.39 + estree-walker: 1.0.1 + picomatch: 2.3.1 + rollup: 2.79.1 + dev: true + /@sinclair/typebox/0.25.23: resolution: {integrity: sha512-VEB8ygeP42CFLWyAJhN5OklpxUliqdNEUcXb4xZ/CINqtYGTjL5ukluKdKzQ0iWdUxyQ7B0539PAUhHKrCNWSQ==} dev: true @@ -2381,6 +2634,12 @@ packages: resolution: {integrity: sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==} dev: true + /@types/accepts/1.3.5: + resolution: {integrity: sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==} + dependencies: + '@types/node': 18.17.14 + dev: true + /@types/babel__core/7.20.0: resolution: {integrity: sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==} dependencies: @@ -2414,34 +2673,52 @@ packages: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} dependencies: '@types/connect': 3.4.35 - '@types/node': 16.18.12 + '@types/node': 18.17.14 + dev: true + + /@types/command-line-args/5.2.1: + resolution: {integrity: sha512-U2OcmS2tj36Yceu+mRuPyUV0ILfau/h5onStcSCzqTENsq0sBiAp2TmaXu1k8fY4McLcPKSYl9FRzn2hx5bI+w==} dev: true /@types/connect/3.4.35: resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} dependencies: - '@types/node': 16.18.12 + '@types/node': 18.17.14 + dev: true + + /@types/content-disposition/0.5.6: + resolution: {integrity: sha512-GmShTb4qA9+HMPPaV2+Up8tJafgi38geFi7vL4qAM7k8BwjoelgHZqEUKJZLvughUw22h6vD/wvwN4IUCaWpDA==} dev: true /@types/conventional-commits-parser/3.0.3: resolution: {integrity: sha512-aoUKfRQYvGMH+spFpOTX9jO4nZoz9/BKp4hlHPxL3Cj2r2Xj+jEcwlXtFIyZr5uL8bh1nbWynDEYaAota+XqPg==} dependencies: - '@types/node': 16.18.12 + '@types/node': 18.17.14 dev: true /@types/cookie/0.4.1: resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} + /@types/cookies/0.7.8: + resolution: {integrity: sha512-y6KhF1GtsLERUpqOV+qZJrjUGzc0GE6UTa0b5Z/LZ7Nm2mKSdCXmS6Kdnl7fctPNnMSouHjxqEWI12/YqQfk5w==} + dependencies: + '@types/connect': 3.4.35 + '@types/express': 4.17.17 + '@types/keygrip': 1.0.2 + '@types/node': 18.17.14 + dev: true + /@types/cors/2.8.13: resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==} dependencies: - '@types/node': 16.18.12 + '@types/node': 18.17.14 dev: true /@types/debug/4.1.7: resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==} dependencies: '@types/ms': 0.7.31 + dev: true /@types/eslint-scope/3.7.4: resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==} @@ -2457,6 +2734,10 @@ packages: '@types/json-schema': 7.0.11 dev: true + /@types/estree/0.0.39: + resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} + dev: true + /@types/estree/0.0.51: resolution: {integrity: sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==} dev: true @@ -2464,7 +2745,7 @@ packages: /@types/express-serve-static-core/4.17.33: resolution: {integrity: sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==} dependencies: - '@types/node': 16.18.12 + '@types/node': 18.17.14 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 dev: true @@ -2481,20 +2762,35 @@ packages: /@types/fs-extra/9.0.13: resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} dependencies: - '@types/node': 14.18.36 + '@types/node': 18.17.14 dev: true /@types/glob/7.2.0: resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} dependencies: '@types/minimatch': 5.1.2 - '@types/node': 16.18.12 + '@types/node': 18.17.14 + dev: true + + /@types/glob/8.1.0: + resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==} + dependencies: + '@types/minimatch': 5.1.2 + '@types/node': 18.17.14 dev: true /@types/graceful-fs/4.1.6: resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==} dependencies: - '@types/node': 16.18.12 + '@types/node': 18.17.14 + dev: true + + /@types/http-assert/1.5.3: + resolution: {integrity: sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==} + dev: true + + /@types/http-errors/2.0.1: + resolution: {integrity: sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==} dev: true /@types/issue-parser/3.0.1: @@ -2531,7 +2827,7 @@ packages: /@types/jsdom/20.0.1: resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} dependencies: - '@types/node': 16.18.12 + '@types/node': 18.17.14 '@types/tough-cookie': 4.0.2 parse5: 7.1.2 dev: true @@ -2544,6 +2840,29 @@ packages: resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} dev: true + /@types/keygrip/1.0.2: + resolution: {integrity: sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==} + dev: true + + /@types/koa-compose/3.2.5: + resolution: {integrity: sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==} + dependencies: + '@types/koa': 2.13.8 + dev: true + + /@types/koa/2.13.8: + resolution: {integrity: sha512-Ugmxmgk/yPRW3ptBTh9VjOLwsKWJuGbymo1uGX0qdaqqL18uJiiG1ZoV0rxCOYSaDGhvEp5Ece02Amx0iwaxQQ==} + dependencies: + '@types/accepts': 1.3.5 + '@types/content-disposition': 0.5.6 + '@types/cookies': 0.7.8 + '@types/http-assert': 1.5.3 + '@types/http-errors': 2.0.1 + '@types/keygrip': 1.0.2 + '@types/koa-compose': 3.2.5 + '@types/node': 18.17.14 + dev: true + /@types/mime/3.0.1: resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==} dev: true @@ -2558,6 +2877,7 @@ packages: /@types/ms/0.7.31: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} + dev: true /@types/mustache/4.2.2: resolution: {integrity: sha512-MUSpfpW0yZbTgjekDbH0shMYBUD+X/uJJJMm9LXN1d5yjl5lCY1vN/eWKD6D1tOtjA6206K0zcIPnUaFMurdNA==} @@ -2566,16 +2886,17 @@ packages: /@types/node-fetch/2.6.2: resolution: {integrity: sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==} dependencies: - '@types/node': 14.18.36 + '@types/node': 18.17.14 form-data: 3.0.1 dev: true - /@types/node/14.18.36: - resolution: {integrity: sha512-FXKWbsJ6a1hIrRxv+FoukuHnGTgEzKYGi7kilfMae96AL9UNkPFNWJEEYWzdRI9ooIkbr4AKldyuSTLql06vLQ==} - dev: true - /@types/node/16.18.12: resolution: {integrity: sha512-vzLe5NaNMjIE3mcddFVGlAXN1LEWueUsMsOJWaT6wWMJGyljHAWHznqfnKUQWGzu7TLPrGvWdNAsvQYW+C0xtw==} + dev: true + + /@types/node/18.17.14: + resolution: {integrity: sha512-ZE/5aB73CyGqgQULkLG87N9GnyGe5TcQjv34pwS8tfBs1IkCh0ASM69mydb2znqd6v0eX+9Ytvk6oQRqu8T1Vw==} + dev: true /@types/normalize-package-data/2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} @@ -2585,6 +2906,10 @@ packages: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: true + /@types/parse5/6.0.3: + resolution: {integrity: sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==} + dev: true + /@types/prettier/2.7.2: resolution: {integrity: sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg==} dev: true @@ -2592,7 +2917,7 @@ packages: /@types/puppeteer/5.4.7: resolution: {integrity: sha512-JdGWZZYL0vKapXF4oQTC5hLVNfOgdPrqeZ1BiQnGk5cB7HeE91EWUiTdVSdQPobRN8rIcdffjiOgCYJ/S8QrnQ==} dependencies: - '@types/node': 14.18.36 + '@types/node': 18.17.14 dev: true /@types/qs/6.9.7: @@ -2613,6 +2938,12 @@ packages: resolution: {integrity: sha512-VtTUcUaJGiJtlBKYwwFIOSvrcnuKmpPGO+x56XijNZnaDpnzKh2VwoTw5hewrOMW2BgjoU+uFbVAvSCW2FpWmA==} dev: true + /@types/resolve/1.17.1: + resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==} + dependencies: + '@types/node': 18.17.14 + dev: true + /@types/semver/7.3.13: resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==} dev: true @@ -2625,19 +2956,17 @@ packages: resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==} dependencies: '@types/mime': 3.0.1 - '@types/node': 16.18.12 + '@types/node': 18.17.14 dev: true - /@types/set-cookie-parser/2.4.2: - resolution: {integrity: sha512-fBZgytwhYAUkj/jC/FAV4RQ5EerRup1YQsXQCh8rZfiHkc4UahC192oH0smGwsXol3cL3A5oETuAHeQHmhXM4w==} - dependencies: - '@types/node': 16.18.12 - dev: false - /@types/stack-utils/2.0.1: resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} dev: true + /@types/statuses/2.0.1: + resolution: {integrity: sha512-vVRgv7WXbhIZzILgr58b4Ki2uqpN/dlVCUBWCMkPbMDlV1CrQrgCl5hnIoUlMrgymGcTmdfVqbs1yWj/IRIRtQ==} + dev: false + /@types/tough-cookie/4.0.2: resolution: {integrity: sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==} dev: true @@ -2646,6 +2975,12 @@ packages: resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} dev: true + /@types/ws/7.4.7: + resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + dependencies: + '@types/node': 18.17.14 + dev: true + /@types/yargs-parser/21.0.0: resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} dev: true @@ -2781,15 +3116,99 @@ packages: semver: 7.3.8 transitivePeerDependencies: - supports-color - - typescript + - typescript + dev: true + + /@typescript-eslint/visitor-keys/5.52.0: + resolution: {integrity: sha512-qMwpw6SU5VHCPr99y274xhbm+PRViK/NATY6qzt+Et7+mThGuFSl/ompj2/hrBlRP/kq+BFdgagnOSgw9TB0eA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.52.0 + eslint-visitor-keys: 3.3.0 + dev: true + + /@web/config-loader/0.1.3: + resolution: {integrity: sha512-XVKH79pk4d3EHRhofete8eAnqto1e8mCRAqPV00KLNFzCWSe8sWmLnqKCqkPNARC6nksMaGrATnA5sPDRllMpQ==} + engines: {node: '>=10.0.0'} + dependencies: + semver: 7.5.4 + dev: true + + /@web/dev-server-core/0.4.1: + resolution: {integrity: sha512-KdYwejXZwIZvb6tYMCqU7yBiEOPfKLQ3V9ezqqEz8DA9V9R3oQWaowckvCpFB9IxxPfS/P8/59OkdzGKQjcIUw==} + engines: {node: '>=10.0.0'} + dependencies: + '@types/koa': 2.13.8 + '@types/ws': 7.4.7 + '@web/parse5-utils': 1.3.1 + chokidar: 3.4.1 + clone: 2.1.2 + es-module-lexer: 1.3.0 + get-stream: 6.0.1 + is-stream: 2.0.1 + isbinaryfile: 5.0.0 + koa: 2.14.2 + koa-etag: 4.0.0 + koa-send: 5.0.1 + koa-static: 5.0.0 + lru-cache: 6.0.0 + mime-types: 2.1.35 + parse5: 6.0.1 + picomatch: 2.3.1 + ws: 7.5.9 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /@web/dev-server-rollup/0.4.1: + resolution: {integrity: sha512-Ebsv7Ovd9MufeH3exvikBJ7GmrZA5OmHnOgaiHcwMJ2eQBJA5/I+/CbRjsLX97ICj/ZwZG//p2ITRz8W3UfSqg==} + engines: {node: '>=10.0.0'} + dependencies: + '@rollup/plugin-node-resolve': 13.3.0_rollup@2.79.1 + '@web/dev-server-core': 0.4.1 + nanocolors: 0.2.13 + parse5: 6.0.1 + rollup: 2.79.1 + whatwg-url: 11.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /@web/dev-server/0.1.38: + resolution: {integrity: sha512-WUq7Zi8KeJ5/UZmmpZ+kzUpUlFlMP/rcreJKYg9Lxiz998KYl4G5Rv24akX0piTuqXG7r6h+zszg8V/hdzjCoA==} + engines: {node: '>=10.0.0'} + hasBin: true + dependencies: + '@babel/code-frame': 7.18.6 + '@types/command-line-args': 5.2.1 + '@web/config-loader': 0.1.3 + '@web/dev-server-core': 0.4.1 + '@web/dev-server-rollup': 0.4.1 + camelcase: 6.3.0 + command-line-args: 5.2.1 + command-line-usage: 7.0.1 + debounce: 1.2.1 + deepmerge: 4.3.0 + ip: 1.1.8 + nanocolors: 0.2.13 + open: 8.4.2 + portfinder: 1.0.32 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate dev: true - /@typescript-eslint/visitor-keys/5.52.0: - resolution: {integrity: sha512-qMwpw6SU5VHCPr99y274xhbm+PRViK/NATY6qzt+Et7+mThGuFSl/ompj2/hrBlRP/kq+BFdgagnOSgw9TB0eA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@web/parse5-utils/1.3.1: + resolution: {integrity: sha512-haCgDchZrAOB9EhBJ5XqiIjBMsS/exsM5Ru7sCSyNkXVEJWskyyKuKMFk66BonnIGMPpDtqDrTUfYEis5Zi3XA==} + engines: {node: '>=10.0.0'} dependencies: - '@typescript-eslint/types': 5.52.0 - eslint-visitor-keys: 3.3.0 + '@types/parse5': 6.0.3 + parse5: 6.0.1 dev: true /@webassemblyjs/ast/1.11.1: @@ -2898,11 +3317,6 @@ packages: '@xtuc/long': 4.2.2 dev: true - /@xmldom/xmldom/0.8.6: - resolution: {integrity: sha512-uRjjusqpoqfmRkTaNuLJ2VohVr67Q5YwDATW3VU7PfzTj6IRaihGrYI7zckGZjxQPBIp63nfvJbM+Yu5ICh0Bg==} - engines: {node: '>=10.0.0'} - dev: false - /@xtuc/ieee754/1.2.0: resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} dev: true @@ -2911,12 +3325,6 @@ packages: resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} dev: true - /@zxing/text-encoding/0.9.0: - resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} - requiresBuild: true - dev: false - optional: true - /JSONStream/1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -3147,6 +3555,16 @@ packages: engines: {node: '>=0.10.0'} dev: true + /array-back/3.1.0: + resolution: {integrity: sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==} + engines: {node: '>=6'} + dev: true + + /array-back/6.2.2: + resolution: {integrity: sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw==} + engines: {node: '>=12.17'} + dev: true + /array-flatten/1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} dev: true @@ -3240,6 +3658,7 @@ packages: /available-typed-arrays/1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} + dev: true /babel-helper-evaluate-path/0.5.0: resolution: {integrity: sha512-mUh0UhS607bGh5wUMAQfOpt2JX2ThXMtppHRdRU1kL7ZLRWIXxoV2UIV1r2cAeeNeU1M5SB5/RSUgUxrK8yOkA==} @@ -3280,7 +3699,7 @@ packages: '@types/babel__core': 7.20.0 babel-plugin-istanbul: 6.1.1 babel-preset-jest: 29.4.3_@babel+core@7.20.12 - chalk: 4.1.1 + chalk: 4.1.2 graceful-fs: 4.2.10 slash: 3.0.0 transitivePeerDependencies: @@ -3299,7 +3718,7 @@ packages: loader-utils: 2.0.4 make-dir: 3.1.0 schema-utils: 2.7.1 - webpack: 5.75.0_@swc+core@1.3.35 + webpack: 5.75.0_mtsvlg4x4u5udzh2pohivgt4x4 dev: true /babel-minify/0.5.2: @@ -3654,6 +4073,12 @@ packages: concat-map: 0.0.1 dev: true + /brace-expansion/2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + /braces/2.3.2_supports-color@6.1.0: resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==} engines: {node: '>=0.10.0'} @@ -3709,16 +4134,28 @@ packages: base64-js: 1.5.1 ieee754: 1.2.1 - /bundle-require/3.1.2_esbuild@0.14.54: - resolution: {integrity: sha512-Of6l6JBAxiyQ5axFxUM6dYeP/W7X2Sozeo/4EYB9sJhL+dqL7TKjg+shwxp6jlu/6ZSERfsYtIpSJ1/x3XkAEA==} + /builtin-modules/3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} + dev: true + + /bundle-require/4.0.1_esbuild@0.17.19: + resolution: {integrity: sha512-9NQkRHlNdNpDBGmLpngF3EFDcwodhMUuLz9PaWYciVcQF9SE4LFjM2DB/xV1Li5JiuDMv7ZUWuC3rGbqR0MAXQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: - esbuild: '>=0.13' + esbuild: '>=0.17' dependencies: - esbuild: 0.14.54 + esbuild: 0.17.19 load-tsconfig: 0.2.3 dev: true + /busboy/1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + dependencies: + streamsearch: 1.1.0 + dev: true + /bytes/3.0.0: resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} engines: {node: '>= 0.8'} @@ -3749,6 +4186,14 @@ packages: unset-value: 1.0.0 dev: true + /cache-content-type/1.0.1: + resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==} + engines: {node: '>= 6.0.0'} + dependencies: + mime-types: 2.1.35 + ylru: 1.3.2 + dev: true + /cachedir/2.3.0: resolution: {integrity: sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==} engines: {node: '>=6'} @@ -3759,6 +4204,7 @@ packages: dependencies: function-bind: 1.1.1 get-intrinsic: 1.2.0 + dev: true /callsites/3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} @@ -3798,6 +4244,13 @@ packages: resolution: {integrity: sha512-XFHJY5dUgmpMV25UqaD4kVq2LsiaU5rS8fb0f17pCoXQiQslzmFgnfOxfvo1bTpTqf7dwG/N/05CnLCnOEKmzA==} dev: true + /chalk-template/0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + dependencies: + chalk: 4.1.2 + dev: true + /chalk/2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -3807,8 +4260,8 @@ packages: supports-color: 5.5.0 dev: true - /chalk/4.1.1: - resolution: {integrity: sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==} + /chalk/4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} dependencies: ansi-styles: 4.3.0 @@ -3924,6 +4377,11 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + /clone/2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + dev: true + /co/4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -3971,6 +4429,26 @@ packages: delayed-stream: 1.0.0 dev: true + /command-line-args/5.2.1: + resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} + engines: {node: '>=4.0.0'} + dependencies: + array-back: 3.1.0 + find-replace: 3.0.0 + lodash.camelcase: 4.3.0 + typical: 4.0.0 + dev: true + + /command-line-usage/7.0.1: + resolution: {integrity: sha512-NCyznE//MuTjwi3y84QVUGEOT+P5oto1e1Pk/jFPVdPPfsG03qpTIl3yw6etR+v73d0lXsoojRpvbru2sqePxQ==} + engines: {node: '>=12.20.0'} + dependencies: + array-back: 6.2.2 + chalk-template: 0.4.0 + table-layout: 3.0.2 + typical: 7.1.1 + dev: true + /commander/2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} dev: true @@ -4148,10 +4626,18 @@ packages: /cookie/0.4.2: resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} engines: {node: '>= 0.6'} + dev: true /cookie/0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} + + /cookies/0.8.0: + resolution: {integrity: sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==} + engines: {node: '>= 0.8'} + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 dev: true /copy-descriptor/0.1.1: @@ -4177,23 +4663,23 @@ packages: vary: 1.1.2 dev: true - /cosmiconfig-typescript-loader/2.0.2_plaptv2cv5vvro2su5yxvauvda: + /cosmiconfig-typescript-loader/2.0.2_f6calhiv3qbku3gmsoec3zvctu: resolution: {integrity: sha512-KmE+bMjWMXJbkWCeY4FJX/npHuZPNr9XF9q9CIQ/bpFwi1qHfCmSiKarrCcRa0LO4fWjk93pVoeRtJAkTGcYNw==} engines: {node: '>=12', npm: '>=6'} peerDependencies: '@types/node': '*' typescript: '>=3' dependencies: - '@types/node': 16.18.12 + '@types/node': 18.17.14 cosmiconfig: 7.1.0 - ts-node: 10.9.1_plaptv2cv5vvro2su5yxvauvda + ts-node: 10.9.1_f6calhiv3qbku3gmsoec3zvctu typescript: 4.9.5 transitivePeerDependencies: - '@swc/core' - '@swc/wasm' dev: true - /cosmiconfig-typescript-loader/4.3.0_gg653o4cxizhrslchmhiad54ma: + /cosmiconfig-typescript-loader/4.3.0_bmci5xihqld5vqu3v4tyk3r5ra: resolution: {integrity: sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==} engines: {node: '>=12', npm: '>=6'} peerDependencies: @@ -4202,9 +4688,9 @@ packages: ts-node: '>=10' typescript: '>=3' dependencies: - '@types/node': 16.18.12 + '@types/node': 18.17.14 cosmiconfig: 8.0.0 - ts-node: 10.9.1_plaptv2cv5vvro2su5yxvauvda + ts-node: 10.9.1_f6calhiv3qbku3gmsoec3zvctu typescript: 4.9.5 dev: true optional: true @@ -4321,6 +4807,10 @@ packages: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} dev: true + /debounce/1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + dev: true + /debug/2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -4344,6 +4834,17 @@ packages: supports-color: 6.1.0 dev: true + /debug/3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + dev: true + /debug/3.2.7_supports-color@6.1.0: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -4366,6 +4867,7 @@ packages: optional: true dependencies: ms: 2.1.2 + dev: true /debug/4.3.4_supports-color@6.1.0: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} @@ -4406,6 +4908,10 @@ packages: resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} dev: true + /deep-equal/1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + dev: true + /deep-equal/1.1.1: resolution: {integrity: sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==} dependencies: @@ -4444,6 +4950,11 @@ packages: dependencies: clone: 1.0.4 + /define-lazy-prop/2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + dev: true + /define-properties/1.2.0: resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} engines: {node: '>= 0.4'} @@ -4492,6 +5003,10 @@ packages: engines: {node: '>=0.4.0'} dev: true + /delegates/1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + dev: true + /depd/1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -4651,7 +5166,7 @@ packages: dependencies: '@types/cookie': 0.4.1 '@types/cors': 2.8.13 - '@types/node': 16.18.12 + '@types/node': 18.17.14 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.4.2 @@ -4745,6 +5260,10 @@ packages: resolution: {integrity: sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==} dev: true + /es-module-lexer/1.3.0: + resolution: {integrity: sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==} + dev: true + /es-set-tostringtag/2.0.1: resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==} engines: {node: '>= 0.4'} @@ -4763,132 +5282,6 @@ packages: is-symbol: 1.0.4 dev: true - /esbuild-android-64/0.14.54: - resolution: {integrity: sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /esbuild-android-arm64/0.14.54: - resolution: {integrity: sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - requiresBuild: true - dev: true - optional: true - - /esbuild-darwin-64/0.14.54: - resolution: {integrity: sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /esbuild-darwin-arm64/0.14.54: - resolution: {integrity: sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: true - optional: true - - /esbuild-freebsd-64/0.14.54: - resolution: {integrity: sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /esbuild-freebsd-arm64/0.14.54: - resolution: {integrity: sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-32/0.14.54: - resolution: {integrity: sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-64/0.14.54: - resolution: {integrity: sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-arm/0.14.54: - resolution: {integrity: sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-arm64/0.14.54: - resolution: {integrity: sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-mips64le/0.14.54: - resolution: {integrity: sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-ppc64le/0.14.54: - resolution: {integrity: sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-riscv64/0.14.54: - resolution: {integrity: sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - requiresBuild: true - dev: true - optional: true - - /esbuild-linux-s390x/0.14.54: - resolution: {integrity: sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - requiresBuild: true - dev: true - optional: true - /esbuild-loader/2.21.0_webpack@5.75.0: resolution: {integrity: sha512-k7ijTkCT43YBSZ6+fBCW1Gin7s46RrJ0VQaM8qA7lq7W+OLsGgtLyFV8470FzYi/4TeDexniTBTPTwZUnXXR5g==} peerDependencies: @@ -4899,93 +5292,10 @@ packages: json5: 2.2.3 loader-utils: 2.0.4 tapable: 2.2.1 - webpack: 5.75.0_@swc+core@1.3.35 + webpack: 5.75.0_mtsvlg4x4u5udzh2pohivgt4x4 webpack-sources: 1.4.3 dev: true - /esbuild-netbsd-64/0.14.54: - resolution: {integrity: sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - requiresBuild: true - dev: true - optional: true - - /esbuild-openbsd-64/0.14.54: - resolution: {integrity: sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - requiresBuild: true - dev: true - optional: true - - /esbuild-sunos-64/0.14.54: - resolution: {integrity: sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - requiresBuild: true - dev: true - optional: true - - /esbuild-windows-32/0.14.54: - resolution: {integrity: sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /esbuild-windows-64/0.14.54: - resolution: {integrity: sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /esbuild-windows-arm64/0.14.54: - resolution: {integrity: sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - requiresBuild: true - dev: true - optional: true - - /esbuild/0.14.54: - resolution: {integrity: sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==} - engines: {node: '>=12'} - hasBin: true - requiresBuild: true - optionalDependencies: - '@esbuild/linux-loong64': 0.14.54 - esbuild-android-64: 0.14.54 - esbuild-android-arm64: 0.14.54 - esbuild-darwin-64: 0.14.54 - esbuild-darwin-arm64: 0.14.54 - esbuild-freebsd-64: 0.14.54 - esbuild-freebsd-arm64: 0.14.54 - esbuild-linux-32: 0.14.54 - esbuild-linux-64: 0.14.54 - esbuild-linux-arm: 0.14.54 - esbuild-linux-arm64: 0.14.54 - esbuild-linux-mips64le: 0.14.54 - esbuild-linux-ppc64le: 0.14.54 - esbuild-linux-riscv64: 0.14.54 - esbuild-linux-s390x: 0.14.54 - esbuild-netbsd-64: 0.14.54 - esbuild-openbsd-64: 0.14.54 - esbuild-sunos-64: 0.14.54 - esbuild-windows-32: 0.14.54 - esbuild-windows-64: 0.14.54 - esbuild-windows-arm64: 0.14.54 - dev: true - /esbuild/0.16.17: resolution: {integrity: sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==} engines: {node: '>=12'} @@ -5016,6 +5326,36 @@ packages: '@esbuild/win32-x64': 0.16.17 dev: true + /esbuild/0.17.19: + resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.17.19 + '@esbuild/android-arm64': 0.17.19 + '@esbuild/android-x64': 0.17.19 + '@esbuild/darwin-arm64': 0.17.19 + '@esbuild/darwin-x64': 0.17.19 + '@esbuild/freebsd-arm64': 0.17.19 + '@esbuild/freebsd-x64': 0.17.19 + '@esbuild/linux-arm': 0.17.19 + '@esbuild/linux-arm64': 0.17.19 + '@esbuild/linux-ia32': 0.17.19 + '@esbuild/linux-loong64': 0.17.19 + '@esbuild/linux-mips64el': 0.17.19 + '@esbuild/linux-ppc64': 0.17.19 + '@esbuild/linux-riscv64': 0.17.19 + '@esbuild/linux-s390x': 0.17.19 + '@esbuild/linux-x64': 0.17.19 + '@esbuild/netbsd-x64': 0.17.19 + '@esbuild/openbsd-x64': 0.17.19 + '@esbuild/sunos-x64': 0.17.19 + '@esbuild/win32-arm64': 0.17.19 + '@esbuild/win32-ia32': 0.17.19 + '@esbuild/win32-x64': 0.17.19 + dev: true + /escalade/3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} @@ -5126,7 +5466,7 @@ packages: '@eslint/eslintrc': 0.4.3 '@humanwhocodes/config-array': 0.5.0 ajv: 6.12.6 - chalk: 4.1.1 + chalk: 4.1.2 cross-spawn: 7.0.3 debug: 4.3.4 doctrine: 3.0.0 @@ -5205,6 +5545,10 @@ packages: engines: {node: '>=4.0'} dev: true + /estree-walker/1.0.1: + resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} + dev: true + /esutils/2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -5222,6 +5566,7 @@ packages: /events/3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + dev: true /eventsource/2.0.2: resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} @@ -5556,6 +5901,13 @@ packages: merge: 2.1.1 dev: true + /find-replace/3.0.0: + resolution: {integrity: sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==} + engines: {node: '>=4.0.0'} + dependencies: + array-back: 3.1.0 + dev: true + /find-root/1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} dev: true @@ -5621,6 +5973,7 @@ packages: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: is-callable: 1.2.7 + dev: true /for-in/1.0.2: resolution: {integrity: sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==} @@ -5645,6 +5998,14 @@ packages: mime-types: 2.1.35 dev: true + /formdata-node/4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + dev: false + /forwarded/0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -5718,6 +6079,7 @@ packages: /function-bind/1.1.1: resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true /function.prototype.name/1.1.5: resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==} @@ -5752,6 +6114,7 @@ packages: function-bind: 1.1.1 has: 1.0.3 has-symbols: 1.0.3 + dev: true /get-package-type/0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} @@ -5838,6 +6201,16 @@ packages: path-is-absolute: 1.0.1 dev: true + /glob/9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.4 + minipass: 4.2.8 + path-scurry: 1.10.1 + dev: true + /global-dirs/0.1.1: resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} engines: {node: '>=4'} @@ -5911,6 +6284,7 @@ packages: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: get-intrinsic: 1.2.0 + dev: true /graceful-fs/4.2.10: resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} @@ -5961,12 +6335,14 @@ packages: /has-symbols/1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} + dev: true /has-tostringtag/1.0.0: resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} engines: {node: '>= 0.4'} dependencies: has-symbols: 1.0.3 + dev: true /has-value/0.3.1: resolution: {integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==} @@ -6004,9 +6380,15 @@ packages: engines: {node: '>= 0.4.0'} dependencies: function-bind: 1.1.1 + dev: true - /headers-polyfill/3.2.5: - resolution: {integrity: sha512-tUCGvt191vNSQgttSyJoibR+VO+I6+iCHIUdhzEMJKE+EAL8BwCN7fUOZlY4ofOelNHsK+gEjxB/B+9N3EWtdA==} + /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==} @@ -6050,6 +6432,14 @@ packages: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} dev: true + /http-assert/1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + dev: true + /http-deceiver/1.2.7: resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} dev: true @@ -6064,6 +6454,17 @@ packages: statuses: 1.5.0 dev: true + /http-errors/1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + dev: true + /http-errors/2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -6219,7 +6620,7 @@ packages: engines: {node: '>=12.0.0'} dependencies: ansi-escapes: 4.3.2 - chalk: 4.1.1 + chalk: 4.1.2 cli-cursor: 3.1.0 cli-width: 3.0.0 external-editor: 3.1.0 @@ -6290,6 +6691,7 @@ packages: dependencies: call-bind: 1.0.2 has-tostringtag: 1.0.0 + dev: true /is-array-buffer/3.0.1: resolution: {integrity: sha512-ASfLknmY8Xa2XtB4wmbz13Wu202baeA18cJBCeCy0wXUHZF0IPyVEXqKEcd+t2fNSLLL1vC6k7lxZEojNbISXQ==} @@ -6327,9 +6729,17 @@ packages: resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} dev: true + /is-builtin-module/3.2.1: + resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} + engines: {node: '>=6'} + dependencies: + builtin-modules: 3.3.0 + dev: true + /is-callable/1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} + dev: true /is-core-module/2.11.0: resolution: {integrity: sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==} @@ -6376,6 +6786,12 @@ packages: kind-of: 6.0.3 dev: true + /is-docker/2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + dev: true + /is-extendable/0.1.1: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} @@ -6416,7 +6832,7 @@ packages: engines: {node: '>= 0.4'} dependencies: has-tostringtag: 1.0.0 - dev: false + dev: true /is-glob/4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} @@ -6428,6 +6844,10 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + /is-module/1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + dev: true + /is-negative-zero/2.0.2: resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} engines: {node: '>= 0.4'} @@ -6561,6 +6981,7 @@ packages: for-each: 0.3.3 gopd: 1.0.1 has-tostringtag: 1.0.0 + dev: true /is-unicode-supported/0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} @@ -6586,10 +7007,22 @@ packages: engines: {node: '>=4'} dev: true + /is-wsl/2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + dependencies: + is-docker: 2.2.1 + dev: true + /isarray/1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} dev: true + /isbinaryfile/5.0.0: + resolution: {integrity: sha512-UDdnyGvMajJUWCkib7Cei/dvyJrrvo4FIrsvSFWdPpXSUorzXrDJ0S+X5Q4ZlasfPjca4yqCNNsjbCeiy8FFeg==} + engines: {node: '>= 14.0.0'} + dev: true + /isexe/2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true @@ -6679,8 +7112,8 @@ packages: '@jest/expect': 29.4.3 '@jest/test-result': 29.4.3 '@jest/types': 29.4.3 - '@types/node': 16.18.12 - chalk: 4.1.1 + '@types/node': 18.17.14 + chalk: 4.1.2 co: 4.6.0 dedent: 0.7.0 is-generator-fn: 2.1.0 @@ -6698,7 +7131,7 @@ packages: - supports-color dev: true - /jest-cli/29.4.3_nw6xvwuzmqp7vps7knduexkcvm: + /jest-cli/29.4.3_v5qag4bu7yd4vl7sd6rt2doplm: resolution: {integrity: sha512-PiiAPuFNfWWolCE6t3ZrDXQc6OsAuM3/tVW0u27UWc1KE+n/HSn5dSE6B2juqN7WP+PP0jAcnKtGmI4u8GMYCg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -6711,11 +7144,11 @@ packages: '@jest/core': 29.4.3_ts-node@10.9.1 '@jest/test-result': 29.4.3 '@jest/types': 29.4.3 - chalk: 4.1.1 + chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.10 import-local: 3.1.0 - jest-config: 29.4.3_nw6xvwuzmqp7vps7knduexkcvm + jest-config: 29.4.3_v5qag4bu7yd4vl7sd6rt2doplm jest-util: 29.4.3 jest-validate: 29.4.3 prompts: 2.4.2 @@ -6726,47 +7159,7 @@ packages: - ts-node dev: true - /jest-config/29.4.3_ghv2zugsw3zjg5rog5rhyka5ja: - resolution: {integrity: sha512-eCIpqhGnIjdUCXGtLhz4gdDoxKSWXKjzNcc5r+0S1GKOp2fwOipx5mRcwa9GB/ArsxJ1jlj2lmlD9bZAsBxaWQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - dependencies: - '@babel/core': 7.20.12 - '@jest/test-sequencer': 29.4.3 - '@jest/types': 29.4.3 - '@types/node': 16.18.12 - babel-jest: 29.4.3_@babel+core@7.20.12 - chalk: 4.1.1 - ci-info: 3.8.0 - deepmerge: 4.3.0 - glob: 7.2.3 - graceful-fs: 4.2.10 - jest-circus: 29.4.3 - jest-environment-node: 29.4.3 - jest-get-type: 29.4.3 - jest-regex-util: 29.4.3 - jest-resolve: 29.4.3 - jest-runner: 29.4.3 - jest-util: 29.4.3 - jest-validate: 29.4.3 - micromatch: 4.0.5 - parse-json: 5.2.0 - pretty-format: 29.4.3 - slash: 3.0.0 - strip-json-comments: 3.1.1 - ts-node: 10.9.1_oe3jy5ze54sjippw2sqzxdlwem - transitivePeerDependencies: - - supports-color - dev: true - - /jest-config/29.4.3_nw6xvwuzmqp7vps7knduexkcvm: + /jest-config/29.4.3_v5qag4bu7yd4vl7sd6rt2doplm: resolution: {integrity: sha512-eCIpqhGnIjdUCXGtLhz4gdDoxKSWXKjzNcc5r+0S1GKOp2fwOipx5mRcwa9GB/ArsxJ1jlj2lmlD9bZAsBxaWQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} peerDependencies: @@ -6781,9 +7174,9 @@ packages: '@babel/core': 7.20.12 '@jest/test-sequencer': 29.4.3 '@jest/types': 29.4.3 - '@types/node': 14.18.36 + '@types/node': 18.17.14 babel-jest: 29.4.3_@babel+core@7.20.12 - chalk: 4.1.1 + chalk: 4.1.2 ci-info: 3.8.0 deepmerge: 4.3.0 glob: 7.2.3 @@ -6801,7 +7194,7 @@ packages: pretty-format: 29.4.3 slash: 3.0.0 strip-json-comments: 3.1.1 - ts-node: 10.9.1_oe3jy5ze54sjippw2sqzxdlwem + ts-node: 10.9.1_x2vjra2lmmhd46xm3mchw7ztui transitivePeerDependencies: - supports-color dev: true @@ -6810,7 +7203,7 @@ packages: resolution: {integrity: sha512-YB+ocenx7FZ3T5O9lMVMeLYV4265socJKtkwgk/6YUz/VsEzYDkiMuMhWzZmxm3wDRQvayJu/PjkjjSkjoHsCA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - chalk: 4.1.1 + chalk: 4.1.2 diff-sequences: 29.4.3 jest-get-type: 29.4.3 pretty-format: 29.4.3 @@ -6828,7 +7221,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.4.3 - chalk: 4.1.1 + chalk: 4.1.2 jest-get-type: 29.4.3 jest-util: 29.4.3 pretty-format: 29.4.3 @@ -6847,7 +7240,7 @@ packages: '@jest/fake-timers': 29.4.3 '@jest/types': 29.4.3 '@types/jsdom': 20.0.1 - '@types/node': 16.18.12 + '@types/node': 18.17.14 jest-mock: 29.4.3 jest-util: 29.4.3 jsdom: 20.0.3 @@ -6864,7 +7257,7 @@ packages: '@jest/environment': 29.4.3 '@jest/fake-timers': 29.4.3 '@jest/types': 29.4.3 - '@types/node': 16.18.12 + '@types/node': 18.17.14 jest-mock: 29.4.3 jest-util: 29.4.3 dev: true @@ -6880,7 +7273,7 @@ packages: dependencies: '@jest/types': 29.4.3 '@types/graceful-fs': 4.1.6 - '@types/node': 16.18.12 + '@types/node': 18.17.14 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.10 @@ -6905,7 +7298,7 @@ packages: resolution: {integrity: sha512-TTciiXEONycZ03h6R6pYiZlSkvYgT0l8aa49z/DLSGYjex4orMUcafuLXYyyEDWB1RKglq00jzwY00Ei7yFNVg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - chalk: 4.1.1 + chalk: 4.1.2 jest-diff: 29.4.3 jest-get-type: 29.4.3 pretty-format: 29.4.3 @@ -6918,7 +7311,7 @@ packages: '@babel/code-frame': 7.18.6 '@jest/types': 29.4.3 '@types/stack-utils': 2.0.1 - chalk: 4.1.1 + chalk: 4.1.2 graceful-fs: 4.2.10 micromatch: 4.0.5 pretty-format: 29.4.3 @@ -6931,7 +7324,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.4.3 - '@types/node': 16.18.12 + '@types/node': 18.17.14 jest-util: 29.4.3 dev: true @@ -6966,7 +7359,7 @@ packages: resolution: {integrity: sha512-GPokE1tzguRyT7dkxBim4wSx6E45S3bOQ7ZdKEG+Qj0Oac9+6AwJPCk0TZh5Vu0xzeX4afpb+eDmgbmZFFwpOw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - chalk: 4.1.1 + chalk: 4.1.2 graceful-fs: 4.2.10 jest-haste-map: 29.4.3 jest-pnp-resolver: 1.2.3_jest-resolve@29.4.3 @@ -6986,8 +7379,8 @@ packages: '@jest/test-result': 29.4.3 '@jest/transform': 29.4.3 '@jest/types': 29.4.3 - '@types/node': 16.18.12 - chalk: 4.1.1 + '@types/node': 18.17.14 + chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.10 jest-docblock: 29.4.3 @@ -7017,8 +7410,8 @@ packages: '@jest/test-result': 29.4.3 '@jest/transform': 29.4.3 '@jest/types': 29.4.3 - '@types/node': 16.18.12 - chalk: 4.1.1 + '@types/node': 18.17.14 + chalk: 4.1.2 cjs-module-lexer: 1.2.2 collect-v8-coverage: 1.0.1 glob: 7.2.3 @@ -7052,7 +7445,7 @@ packages: '@types/babel__traverse': 7.18.3 '@types/prettier': 2.7.2 babel-preset-current-node-syntax: 1.0.1_@babel+core@7.20.12 - chalk: 4.1.1 + chalk: 4.1.2 expect: 29.4.3 graceful-fs: 4.2.10 jest-diff: 29.4.3 @@ -7073,8 +7466,8 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.4.3 - '@types/node': 16.18.12 - chalk: 4.1.1 + '@types/node': 18.17.14 + chalk: 4.1.2 ci-info: 3.8.0 graceful-fs: 4.2.10 picomatch: 2.3.1 @@ -7086,7 +7479,7 @@ packages: dependencies: '@jest/types': 29.4.3 camelcase: 6.3.0 - chalk: 4.1.1 + chalk: 4.1.2 jest-get-type: 29.4.3 leven: 3.1.0 pretty-format: 29.4.3 @@ -7098,9 +7491,9 @@ packages: dependencies: '@jest/test-result': 29.4.3 '@jest/types': 29.4.3 - '@types/node': 16.18.12 + '@types/node': 18.17.14 ansi-escapes: 4.3.2 - chalk: 4.1.1 + chalk: 4.1.2 emittery: 0.13.1 jest-util: 29.4.3 string-length: 4.0.2 @@ -7110,7 +7503,7 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 16.18.12 + '@types/node': 18.17.14 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true @@ -7119,13 +7512,13 @@ packages: resolution: {integrity: sha512-GLHN/GTAAMEy5BFdvpUfzr9Dr80zQqBrh0fz1mtRMe05hqP45+HfQltu7oTBfduD0UeZs09d+maFtFYAXFWvAA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@types/node': 16.18.12 + '@types/node': 18.17.14 jest-util: 29.4.3 merge-stream: 2.0.0 supports-color: 8.1.1 dev: true - /jest/29.4.3_nw6xvwuzmqp7vps7knduexkcvm: + /jest/29.4.3_v5qag4bu7yd4vl7sd6rt2doplm: resolution: {integrity: sha512-XvK65feuEFGZT8OO0fB/QAQS+LGHvQpaadkH5p47/j3Ocqq3xf2pK9R+G0GzgfuhXVxEv76qCOOcMb5efLk6PA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -7138,7 +7531,7 @@ packages: '@jest/core': 29.4.3_ts-node@10.9.1 '@jest/types': 29.4.3 import-local: 3.1.0 - jest-cli: 29.4.3_nw6xvwuzmqp7vps7knduexkcvm + jest-cli: 29.4.3_v5qag4bu7yd4vl7sd6rt2doplm transitivePeerDependencies: - '@types/node' - supports-color @@ -7272,6 +7665,13 @@ packages: engines: {'0': node >= 0.2.0} dev: true + /keygrip/1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + dependencies: + tsscmp: 1.0.6 + dev: true + /killable/1.0.1: resolution: {integrity: sha512-LzqtLKlUwirEUyl/nicirVmNiPvYs7l5n8wOPP7fyJVpUPkvCnW/vuiXGpylGUlnPDnB7311rARzAt3Mhswpjg==} dev: true @@ -7305,6 +7705,76 @@ packages: engines: {node: '>=6'} dev: true + /koa-compose/4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + dev: true + + /koa-convert/2.0.0: + resolution: {integrity: sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==} + engines: {node: '>= 10'} + dependencies: + co: 4.6.0 + koa-compose: 4.1.0 + dev: true + + /koa-etag/4.0.0: + resolution: {integrity: sha512-1cSdezCkBWlyuB9l6c/IFoe1ANCDdPBxkDkRiaIup40xpUub6U/wwRXoKBZw/O5BifX9OlqAjYnDyzM6+l+TAg==} + dependencies: + etag: 1.8.1 + dev: true + + /koa-send/5.0.1: + resolution: {integrity: sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==} + engines: {node: '>= 8'} + dependencies: + debug: 4.3.4 + http-errors: 1.8.1 + resolve-path: 1.4.0 + transitivePeerDependencies: + - supports-color + dev: true + + /koa-static/5.0.0: + resolution: {integrity: sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==} + engines: {node: '>= 7.6.0'} + dependencies: + debug: 3.2.7 + koa-send: 5.0.1 + transitivePeerDependencies: + - supports-color + dev: true + + /koa/2.14.2: + resolution: {integrity: sha512-VFI2bpJaodz6P7x2uyLiX6RLYpZmOJqNmoCst/Yyd7hQlszyPwG/I9CQJ63nOtKSxpt5M7NH67V6nJL2BwCl7g==} + engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4} + dependencies: + accepts: 1.3.8 + cache-content-type: 1.0.1 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookies: 0.8.0 + debug: 4.3.4 + delegates: 1.0.0 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 1.6.3 + is-generator-function: 1.0.10 + koa-compose: 4.1.0 + koa-convert: 2.0.0 + on-finished: 2.4.1 + only: 0.0.2 + parseurl: 1.3.3 + statuses: 1.5.0 + type-is: 1.6.18 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: true + /leven/2.1.0: resolution: {integrity: sha512-nvVPLpIHUxCUoRLrFqTgSxXJ614d8AgQoWl7zPe/2VadE8+1dpU3LBhowRuBAcuwruWtOdD8oYC9jDNJjXDPyA==} engines: {node: '>=0.10.0'} @@ -7423,6 +7893,14 @@ packages: p-locate: 5.0.0 dev: true + /lodash.assignwith/4.2.0: + resolution: {integrity: sha512-ZznplvbvtjK2gMvnQ1BR/zqPFZmS6jbK4p+6Up4xcRYA7yMIwxHCfbTcrYxXKzzqLsQ05eJPVznEW3tuwV7k1g==} + dev: true + + /lodash.camelcase/4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + dev: true + /lodash.capitalize/4.2.1: resolution: {integrity: sha512-kZzYOKspf8XVX5AvmQF94gQW0lejFVgb80G85bU4ZWzoJ6C03PQg3coYAUpSTpQWelrZELd3XWgHzw4Ck5kaIw==} dev: true @@ -7480,7 +7958,7 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} dependencies: - chalk: 4.1.1 + chalk: 4.1.2 is-unicode-supported: 0.1.0 /log-update/4.0.0: @@ -7503,6 +7981,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /lru-cache/10.0.1: + resolution: {integrity: sha512-IJ4uwUTi2qCccrioU6g9g/5rvvVl13bsdczUUcqbciD9iLr095yj8DQKdObriEvuNSx325N1rV1O0sJFszx75g==} + engines: {node: 14 || >=16.14} + dev: true + /lru-cache/5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} dependencies: @@ -7695,6 +8178,13 @@ packages: brace-expansion: 1.1.11 dev: true + /minimatch/8.0.4: + resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist-options/4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -7712,6 +8202,16 @@ packages: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} dev: true + /minipass/4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + dev: true + + /minipass/7.0.3: + resolution: {integrity: sha512-LhbbwCfz3vsb12j/WkWQPZfKTsgqIe1Nf/ti1pKjYESGLHIVjWU96G9/ljLH4F9mWNVhlQOm0VySdAWzf05dpg==} + engines: {node: '>=16 || 14 >=14.17'} + dev: true + /mixin-deep/1.3.2: resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} engines: {node: '>=0.10.0'} @@ -7738,6 +8238,7 @@ packages: /ms/2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true /ms/2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -7771,6 +8272,10 @@ packages: thenify-all: 1.6.0 dev: true + /nanocolors/0.2.13: + resolution: {integrity: sha512-0n3mSAQLPpGLV9ORXT5+C/D4mwew7Ebws69Hx4E2sgz2ZA5+32Q80B9tL8PbL7XHnRDiAxH/pnrUJ9a4fkTNTA==} + dev: true + /nanomatch/1.2.13_supports-color@6.1.0: resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} engines: {node: '>=0.10.0'} @@ -7811,6 +8316,11 @@ packages: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} dev: true + /node-domexception/1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + dev: false + /node-fetch/2.6.7: resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} engines: {node: 4.x || >=6.0.0} @@ -7863,7 +8373,7 @@ packages: dependencies: hosted-git-info: 4.1.0 is-core-module: 2.11.0 - semver: 7.3.8 + semver: 7.5.4 validate-npm-package-license: 3.0.4 dev: true @@ -8000,6 +8510,19 @@ packages: mimic-fn: 4.0.0 dev: true + /only/0.0.2: + resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==} + dev: true + + /open/8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + dev: true + /opn/5.5.0: resolution: {integrity: sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA==} engines: {node: '>=4'} @@ -8036,7 +8559,7 @@ packages: engines: {node: '>=10'} dependencies: bl: 4.1.0 - chalk: 4.1.1 + chalk: 4.1.2 cli-cursor: 3.1.0 cli-spinners: 2.7.0 is-interactive: 1.0.0 @@ -8116,22 +8639,22 @@ packages: engines: {node: '>=6'} dev: true - /page-with/0.5.1_@swc+core@1.3.35: - resolution: {integrity: sha512-830oKHY2kfhPuc3vsaaeqrwCH1FkOhopFQ0JmSPObGI4yVZQLYYpRW4frNP4hYyuxVgC0zbwMqr+Z4ESgDX5Sw==} + /page-with/0.6.1_mtsvlg4x4u5udzh2pohivgt4x4: + resolution: {integrity: sha512-5J58fSpc8CKonUWCPsh8b2LctFrNSOpXQ8O3tB+/iJvixOQf1qHp4+cDLiIVsl/WiuheXdZTzMcuR0KLQMaWcg==} dependencies: - '@open-draft/until': 1.0.3 + '@open-draft/until': 2.1.0 '@types/debug': 4.1.7 '@types/express': 4.17.17 '@types/mustache': 4.2.2 '@types/uuid': 8.3.4 debug: 4.3.4 express: 4.18.2 - headers-polyfill: 3.2.5 + headers-polyfill: 3.2.3 memfs: 3.4.13 mustache: 4.2.0 playwright: 1.30.0 uuid: 8.3.2 - webpack: 5.75.0_@swc+core@1.3.35 + webpack: 5.75.0_mtsvlg4x4u5udzh2pohivgt4x4 webpack-merge: 5.8.0 transitivePeerDependencies: - '@swc/core' @@ -8163,6 +8686,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /parse5/6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + dev: true + /parse5/7.1.2: resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} dependencies: @@ -8217,6 +8744,14 @@ packages: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true + /path-scurry/1.10.1: + resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + lru-cache: 10.0.1 + minipass: 7.0.3 + dev: true + /path-to-regexp/0.1.7: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} dev: true @@ -8347,6 +8882,17 @@ packages: playwright-core: 1.30.0 dev: true + /portfinder/1.0.32: + resolution: {integrity: sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==} + engines: {node: '>= 0.12.0'} + dependencies: + async: 2.6.4 + debug: 3.2.7 + mkdirp: 0.5.6 + transitivePeerDependencies: + - supports-color + dev: true + /portfinder/1.0.32_supports-color@6.1.0: resolution: {integrity: sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==} engines: {node: '>= 0.12.0'} @@ -8376,7 +8922,7 @@ packages: optional: true dependencies: lilconfig: 2.0.6 - ts-node: 10.9.1_oe3jy5ze54sjippw2sqzxdlwem + ts-node: 10.9.1_x2vjra2lmmhd46xm3mchw7ztui yaml: 1.10.2 dev: true @@ -8733,6 +9279,14 @@ packages: global-dirs: 0.1.1 dev: true + /resolve-path/1.4.0: + resolution: {integrity: sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==} + engines: {node: '>= 0.8'} + dependencies: + http-errors: 1.6.3 + path-is-absolute: 1.0.1 + dev: true + /resolve-url/0.2.1: resolution: {integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==} deprecated: https://github.com/lydell/resolve-url#deprecated @@ -8800,6 +9354,14 @@ packages: fsevents: 2.3.2 dev: true + /rollup/3.29.0: + resolution: {integrity: sha512-nszM8DINnx1vSS+TpbWKMkxem0CDWk3cSit/WWCBVs9/JZ1I/XLwOsiUglYuYReaeWWSsW9kge5zE5NZtf/a4w==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + /run-async/2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -9017,10 +9579,6 @@ packages: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} dev: true - /set-cookie-parser/2.5.1: - resolution: {integrity: sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ==} - dev: false - /set-value/2.0.1: resolution: {integrity: sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==} engines: {node: '>=0.10.0'} @@ -9370,7 +9928,6 @@ packages: /statuses/2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - dev: true /stream-combiner2/1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} @@ -9379,18 +9936,22 @@ packages: readable-stream: 2.3.7 dev: true + /stream-read-all/3.0.1: + resolution: {integrity: sha512-EWZT9XOceBPlVJRrYcykW8jyRSZYbkb/0ZK36uLEmoWVO5gxBOnntNTseNzfREsqxqdfEGQrD8SXQ3QWbBmq8A==} + engines: {node: '>=10'} + dev: true + /stream-shift/1.0.1: resolution: {integrity: sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==} dev: true - /strict-event-emitter/0.2.8: - resolution: {integrity: sha512-KDf/ujU8Zud3YaLtMCcTI4xkZlZVIYxTLr+XIULexP+77EEVWixeXroLUXQXiVtH4XH2W7jr/3PT1v3zBuvc3A==} - dependencies: - events: 3.3.0 - dev: false + /streamsearch/1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + dev: true - /strict-event-emitter/0.4.6: - resolution: {integrity: sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==} + /strict-event-emitter/0.5.0: + resolution: {integrity: sha512-sqnMpVJLSB3daNO6FcvsEk4Mq5IJeAwDeH80DP1S8+pgxrF6yZnE1+VeapesGled7nEcIkz1Ax87HzaIy+02kA==} dev: false /string-argv/0.3.1: @@ -9572,6 +10133,20 @@ packages: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} dev: true + /table-layout/3.0.2: + resolution: {integrity: sha512-rpyNZYRw+/C+dYkcQ3Pr+rLxW4CfHpXjPDnG7lYhdRoUcZTUt+KEsX+94RGp/aVp/MQU35JCITv2T/beY4m+hw==} + engines: {node: '>=12.17'} + hasBin: true + dependencies: + '@75lb/deep-merge': 1.1.1 + array-back: 6.2.2 + command-line-args: 5.2.1 + command-line-usage: 7.0.1 + stream-read-all: 3.0.1 + typical: 7.1.1 + wordwrapjs: 5.1.0 + dev: true + /table/6.8.1: resolution: {integrity: sha512-Y4X9zqrCftUhMeH2EptSSERdVKt/nEdijTOacGD/97EKjhQ/Qs8RTlEGABSJNNN8lac9kheH+af7yAkEWlgneA==} engines: {node: '>=10.0.0'} @@ -9588,7 +10163,7 @@ packages: engines: {node: '>=6'} dev: true - /terser-webpack-plugin/5.3.6_gwpkmym7uf5m6snr3dgsgj5rrq: + /terser-webpack-plugin/5.3.6_46rrhsymls7zkxn67al7zvwy5y: resolution: {integrity: sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==} engines: {node: '>= 10.13.0'} peerDependencies: @@ -9606,11 +10181,12 @@ packages: dependencies: '@jridgewell/trace-mapping': 0.3.17 '@swc/core': 1.3.35 + esbuild: 0.17.19 jest-worker: 27.5.1 schema-utils: 3.1.1 serialize-javascript: 6.0.1 terser: 5.16.4 - webpack: 5.75.0_@swc+core@1.3.35 + webpack: 5.75.0_mtsvlg4x4u5udzh2pohivgt4x4 dev: true /terser/5.16.4: @@ -9781,7 +10357,7 @@ packages: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true - /ts-node/10.9.1_oe3jy5ze54sjippw2sqzxdlwem: + /ts-node/10.9.1_f6calhiv3qbku3gmsoec3zvctu: resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -9801,19 +10377,19 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.3 - '@types/node': 14.18.36 + '@types/node': 18.17.14 acorn: 8.8.2 acorn-walk: 8.2.0 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.0.2 + typescript: 4.9.5 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 dev: true - /ts-node/10.9.1_plaptv2cv5vvro2su5yxvauvda: + /ts-node/10.9.1_x2vjra2lmmhd46xm3mchw7ztui: resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -9833,14 +10409,14 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.3 - '@types/node': 16.18.12 + '@types/node': 18.17.14 acorn: 8.8.2 acorn-walk: 8.2.0 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 4.9.5 + typescript: 5.0.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 dev: true @@ -9852,13 +10428,19 @@ packages: /tslib/2.5.0: resolution: {integrity: sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==} - /tsup/5.12.9_4s7jzcjqpdttwnwh3e3glkuq6y: - resolution: {integrity: sha512-dUpuouWZYe40lLufo64qEhDpIDsWhRbr2expv5dHEMjwqeKJS2aXA/FPqs1dxO4T6mBojo7rvo3jP9NNzaKyDg==} + /tsscmp/1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + dev: true + + /tsup/6.7.0_4s7jzcjqpdttwnwh3e3glkuq6y: + resolution: {integrity: sha512-L3o8hGkaHnu5TdJns+mCqFsDBo83bJ44rlK7e6VdanIvpea4ArPcU3swWGsLVbXak1PqQx/V+SSmFPujBK+zEQ==} + engines: {node: '>=14.18'} hasBin: true peerDependencies: '@swc/core': ^1 postcss: ^8.4.12 - typescript: ^4.1.0 + typescript: '>=4.1.0' peerDependenciesMeta: '@swc/core': optional: true @@ -9868,17 +10450,17 @@ packages: optional: true dependencies: '@swc/core': 1.3.35 - bundle-require: 3.1.2_esbuild@0.14.54 + bundle-require: 4.0.1_esbuild@0.17.19 cac: 6.7.14 chokidar: 3.4.1 debug: 4.3.4 - esbuild: 0.14.54 + esbuild: 0.17.19 execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 postcss-load-config: 3.1.4_ts-node@10.9.1 resolve-from: 5.0.0 - rollup: 2.79.1 + rollup: 3.29.0 source-map: 0.8.0-beta.0 sucrase: 3.29.0 tree-kill: 1.2.2 @@ -9974,6 +10556,16 @@ packages: hasBin: true dev: true + /typical/4.0.0: + resolution: {integrity: sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==} + engines: {node: '>=8'} + dev: true + + /typical/7.1.1: + resolution: {integrity: sha512-T+tKVNs6Wu7IWiAce5BgMd7OZfNYUndHwc5MknN+UHOudi7sGZzuHdCadllRuqJ3fPtgFtIH9+lt9qRv6lmpfA==} + engines: {node: '>=12.17'} + dev: true + /unbox-primitive/1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: @@ -9983,6 +10575,13 @@ packages: which-boxed-primitive: 1.0.2 dev: true + /undici/5.23.0: + resolution: {integrity: sha512-1D7w+fvRsqlQ9GscLBwcAJinqcZGHUKjbOmXdlE/v8BvEGXjeWAax+341q44EuTcHXXnfyKNbKRq4Lg7OzhMmg==} + engines: {node: '>=14.0'} + dependencies: + busboy: 1.6.0 + dev: true + /unicode-canonical-property-names-ecmascript/2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} @@ -10074,7 +10673,7 @@ packages: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.1.1 - webpack: 5.75.0_@swc+core@1.3.35 + webpack: 5.75.0_mtsvlg4x4u5udzh2pohivgt4x4 dev: true /url-parse/1.5.10: @@ -10109,16 +10708,6 @@ packages: object.getownpropertydescriptors: 2.1.5 dev: true - /util/0.12.5: - resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} - dependencies: - inherits: 2.0.4 - is-arguments: 1.1.1 - is-generator-function: 1.0.10 - is-typed-array: 1.1.10 - which-typed-array: 1.1.9 - dev: false - /utils-merge/1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -10196,12 +10785,9 @@ packages: dependencies: defaults: 1.0.4 - /web-encoding/1.1.5: - resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} - dependencies: - util: 0.12.5 - optionalDependencies: - '@zxing/text-encoding': 0.9.0 + /web-streams-polyfill/4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} dev: false /webidl-conversions/3.0.1: @@ -10226,7 +10812,7 @@ packages: mime: 2.6.0 mkdirp: 0.5.6 range-parser: 1.2.1 - webpack: 5.75.0_@swc+core@1.3.35 + webpack: 5.75.0_mtsvlg4x4u5udzh2pohivgt4x4 webpack-log: 2.0.0 dev: true @@ -10270,7 +10856,7 @@ packages: strip-ansi: 3.0.1 supports-color: 6.1.0 url: 0.11.0 - webpack: 5.75.0_@swc+core@1.3.35 + webpack: 5.75.0_mtsvlg4x4u5udzh2pohivgt4x4 webpack-dev-middleware: 3.7.3_webpack@5.75.0 webpack-log: 2.0.0 ws: 6.2.2 @@ -10280,7 +10866,7 @@ packages: - utf-8-validate dev: true - /webpack-http-server/0.5.0_@swc+core@1.3.35: + /webpack-http-server/0.5.0_mtsvlg4x4u5udzh2pohivgt4x4: resolution: {integrity: sha512-kyewxAnzmDuZxe09fn/Bb0PeEnaDxHChYKFVsMy4oeBUs9Cyv2j1uEgzQJ7ljPFexLU8ongUS4i4O+e22CeBZQ==} dependencies: '@types/express': 4.17.17 @@ -10289,7 +10875,7 @@ packages: memfs: 3.4.13 mustache: 4.2.0 outvariant: 1.4.0 - webpack: 5.75.0_@swc+core@1.3.35 + webpack: 5.75.0_mtsvlg4x4u5udzh2pohivgt4x4 transitivePeerDependencies: - '@swc/core' - esbuild @@ -10326,7 +10912,7 @@ packages: engines: {node: '>=10.13.0'} dev: true - /webpack/5.75.0_@swc+core@1.3.35: + /webpack/5.75.0_mtsvlg4x4u5udzh2pohivgt4x4: resolution: {integrity: sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==} engines: {node: '>=10.13.0'} hasBin: true @@ -10357,7 +10943,7 @@ packages: neo-async: 2.6.2 schema-utils: 3.1.1 tapable: 2.2.1 - terser-webpack-plugin: 5.3.6_gwpkmym7uf5m6snr3dgsgj5rrq + terser-webpack-plugin: 5.3.6_46rrhsymls7zkxn67al7zvwy5y watchpack: 2.4.0 webpack-sources: 3.2.3 transitivePeerDependencies: @@ -10438,6 +11024,7 @@ packages: gopd: 1.0.1 has-tostringtag: 1.0.0 is-typed-array: 1.1.10 + dev: true /which/1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} @@ -10463,6 +11050,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /wordwrapjs/5.1.0: + resolution: {integrity: sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg==} + engines: {node: '>=12.17'} + dev: true + /wrap-ansi/5.1.0: resolution: {integrity: sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==} engines: {node: '>=6'} @@ -10515,6 +11107,19 @@ packages: async-limiter: 1.0.1 dev: true + /ws/7.5.9: + resolution: {integrity: sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + /ws/8.11.0: resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==} engines: {node: '>=10.0.0'} @@ -10630,6 +11235,11 @@ packages: yargs-parser: 21.1.1 dev: true + /ylru/1.3.2: + resolution: {integrity: sha512-RXRJzMiK6U2ye0BlGGZnmpwJDPgakn6aNQ0A7gHRbD4I0uvK4TW6UqkK1V0pp9jskjJBAXd3dRrbzWkqJ+6cxA==} + engines: {node: '>= 4.0.0'} + dev: true + /yn/3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} diff --git a/src/browser/index.ts b/src/browser/index.ts new file mode 100644 index 000000000..0eafbe76f --- /dev/null +++ b/src/browser/index.ts @@ -0,0 +1,3 @@ +export { setupWorker } from './setupWorker/setupWorker' +export type { SetupWorker, StartOptions } from './setupWorker/glossary' +export { SetupWorkerApi } from './setupWorker/setupWorker' diff --git a/src/setupWorker/glossary.ts b/src/browser/setupWorker/glossary.ts similarity index 77% rename from src/setupWorker/glossary.ts rename to src/browser/setupWorker/glossary.ts index d0370cff9..23017c6ec 100644 --- a/src/setupWorker/glossary.ts +++ b/src/browser/setupWorker/glossary.ts @@ -1,20 +1,17 @@ -import { FlatHeadersObject } from 'headers-polyfill' import { Emitter } from 'strict-event-emitter' import { LifeCycleEventEmitter, LifeCycleEventsMap, SharedOptions, -} from '../sharedOptions' +} from '~/core/sharedOptions' import { ServiceWorkerMessage } from './start/utils/createMessageChannel' import { - DefaultBodyType, RequestHandler, RequestHandlerDefaultInfo, -} from '../handlers/RequestHandler' +} from '~/core/handlers/RequestHandler' import type { HttpRequestEventMap, Interceptor } from '@mswjs/interceptors' -import { Path } from '../utils/matching/matchRequestUrl' -import { RequiredDeep } from '../typeUtils' -import { MockedRequest } from '../utils/request/MockedRequest' +import { Path } from '~/core/utils/matching/matchRequestUrl' +import { RequiredDeep } from '~/core/typeUtils' export type ResolvedPath = Path | URL @@ -37,15 +34,11 @@ type RequestWithoutMethods = Omit< */ export interface ServiceWorkerIncomingRequest extends RequestWithoutMethods { /** - * Unique UUID of the request generated once the request is - * captured by the "fetch" event in the Service Worker. + * Unique ID of the request generated once the request is + * intercepted by the "fetch" event in the Service Worker. */ id: string - - /** - * Text response body. - */ - body?: string + body?: ArrayBuffer | null } export type ServiceWorkerIncomingResponse = Pick< @@ -53,6 +46,7 @@ export type ServiceWorkerIncomingResponse = Pick< 'type' | 'ok' | 'status' | 'statusText' | 'body' | 'headers' | 'redirected' > & { requestId: string + isMockedResponse: boolean } /** @@ -77,13 +71,17 @@ export type ServiceWorkerOutgoingEventTypes = | 'KEEPALIVE_REQUEST' | 'CLIENT_CLOSED' +export interface StringifiedResponse extends ResponseInit { + body: string | ArrayBuffer | ReadableStream | null +} + /** * Map of the events that can be sent to the Service Worker * only as a part of a single `fetch` event handler. */ export interface ServiceWorkerFetchEventMap { - MOCK_RESPONSE(payload: SerializedResponse): void - MOCK_RESPONSE_START(payload: SerializedResponse): void + MOCK_RESPONSE(payload: StringifiedResponse): void + MOCK_RESPONSE_START(payload: StringifiedResponse): void MOCK_NOT_FOUND(): void NETWORK_ERROR(payload: { name: string; message: string }): void @@ -95,15 +93,17 @@ export interface ServiceWorkerBroadcastChannelMessageMap { MOCK_RESPONSE_END(): void } -export type WorkerLifecycleEventsMap = LifeCycleEventsMap +export interface StrictEventListener { + (event: EventType): void +} export interface SetupWorkerInternalContext { isMockingEnabled: boolean startOptions: RequiredDeep worker: ServiceWorker | null registration: ServiceWorkerRegistration | null - requestHandlers: RequestHandler[] - emitter: Emitter + requestHandlers: Array + emitter: Emitter keepAliveInterval?: number workerChannel: { /** @@ -128,10 +128,10 @@ export interface SetupWorkerInternalContext { * Adds an event listener on the given target. * Returns a clean-up function that removes that listener. */ - addListener( + addListener( target: EventTarget, eventType: string, - listener: (event: EventType) => void, + callback: StrictEventListener, ): () => void /** * Removes all currently attached listeners. @@ -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 */ @@ -194,14 +197,6 @@ export interface StartOptions extends SharedOptions { findWorker?: FindWorker } -export interface SerializedResponse { - status: number - statusText: string - headers: FlatHeadersObject - body: BodyType - delay?: number -} - export type StartReturnType = Promise export type StartHandler = ( options: RequiredDeep, @@ -212,54 +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< - RequestHandler< - RequestHandlerDefaultInfo, - MockedRequest, - any, - MockedRequest - > - > + listHandlers(): ReadonlyArray> /** - * Lists all active request handlers. - * @see {@link https://mswjs.io/docs/api/setup-worker/print-handlers `worker.printHandlers()`} + * 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} */ - printHandlers: () => void - - events: LifeCycleEventEmitter + events: LifeCycleEventEmitter } diff --git a/src/setupWorker/setupWorker.node.test.ts b/src/browser/setupWorker/setupWorker.node.test.ts similarity index 100% rename from src/setupWorker/setupWorker.node.test.ts rename to src/browser/setupWorker/setupWorker.node.test.ts diff --git a/src/setupWorker/setupWorker.ts b/src/browser/setupWorker/setupWorker.ts similarity index 64% rename from src/setupWorker/setupWorker.ts rename to src/browser/setupWorker/setupWorker.ts index 3246ccbb0..b89376641 100644 --- a/src/setupWorker/setupWorker.ts +++ b/src/browser/setupWorker/setupWorker.ts @@ -3,7 +3,6 @@ import { isNodeProcess } from 'is-node-process' import { SetupWorkerInternalContext, ServiceWorkerIncomingEventsMap, - WorkerLifecycleEventsMap, StartReturnType, StopHandler, StartHandler, @@ -12,23 +11,25 @@ import { import { createStartHandler } from './start/createStartHandler' import { createStop } from './stop/createStop' import { ServiceWorkerMessage } from './start/utils/createMessageChannel' -import { RequestHandler } from '../handlers/RequestHandler' +import { RequestHandler } from '~/core/handlers/RequestHandler' import { DEFAULT_START_OPTIONS } from './start/utils/prepareStartHandler' import { createFallbackStart } from './start/createFallbackStart' import { createFallbackStop } from './stop/createFallbackStop' -import { devUtils } from '../utils/internal/devUtils' -import { SetupApi } from '../SetupApi' -import { mergeRight } from '../utils/internal/mergeRight' +import { devUtils } from '~/core/utils/internal/devUtils' +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 eventType: string - callback: EventListener + callback: EventListenerOrEventListenerObject } export class SetupWorkerApi - extends SetupApi + extends SetupApi implements SetupWorker { private context: SetupWorkerInternalContext @@ -51,7 +52,7 @@ export class SetupWorkerApi } private createWorkerContext(): SetupWorkerInternalContext { - const context = { + const context: SetupWorkerInternalContext = { // Mocking is not considered enabled until the worker // signals back the successful activation event. isMockingEnabled: false, @@ -61,55 +62,41 @@ export class SetupWorkerApi requestHandlers: this.currentHandlers, emitter: this.emitter, workerChannel: { - on: ( - eventType: EventType, - callback: ( - event: MessageEvent, - message: ServiceWorkerMessage< - EventType, - ServiceWorkerIncomingEventsMap[EventType] - >, - ) => void, - ) => { - this.context.events.addListener( - navigator.serviceWorker, - 'message', - (event: MessageEvent) => { - // Avoid messages broadcasted from unrelated workers. - if (event.source !== this.context.worker) { - return - } + on: (eventType, callback) => { + this.context.events.addListener< + MessageEvent> + >(navigator.serviceWorker, 'message', (event) => { + // Avoid messages broadcasted from unrelated workers. + if (event.source !== this.context.worker) { + return + } - const message = event.data as ServiceWorkerMessage< - typeof eventType, - any - > + const message = event.data - if (!message) { - return - } + if (!message) { + return + } - if (message.type === eventType) { - callback(event, message) - } - }, - ) + if (message.type === eventType) { + callback(event, message) + } + }) }, - send: (type: any) => { + send: (type) => { this.context.worker?.postMessage(type) }, }, events: { - addListener: ( - target: EventTarget, - eventType: string, - callback: EventListener, - ) => { - target.addEventListener(eventType, callback) - this.listeners.push({ eventType, target, callback }) + addListener: (target, eventType, callback) => { + target.addEventListener(eventType, callback as EventListener) + this.listeners.push({ + eventType, + target, + callback: callback as EventListener, + }) return () => { - target.removeEventListener(eventType, callback) + target.removeEventListener(eventType, callback as EventListener) } }, removeAllListeners: () => { @@ -118,9 +105,7 @@ export class SetupWorkerApi } this.listeners = [] }, - once: ( - eventType: EventType, - ) => { + once: (eventType) => { const bindings: Array<() => void> = [] return new Promise< @@ -158,8 +143,11 @@ export class SetupWorkerApi }) }, }, - useFallbackMode: - !('serviceWorker' in navigator) || location.protocol === 'file:', + supports: { + serviceWorkerApi: + !('serviceWorker' in navigator) || location.protocol === 'file:', + readableStreamTransfer: supportsReadableStreamTransfer(), + }, } /** @@ -172,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) @@ -192,26 +180,6 @@ export class SetupWorkerApi return await this.startHandler(this.context.startOptions, options) } - public printHandlers(): void { - const handlers = this.listHandlers() - - handlers.forEach((handler) => { - const { header, callFrame } = handler.info - const pragma = handler.info.hasOwnProperty('operationType') - ? '[graphql]' - : '[rest]' - - console.groupCollapsed(`${pragma} ${header}`) - - if (callFrame) { - console.log(`Declaration: ${callFrame}`) - } - - console.log('Handler:', handler) - console.groupEnd() - }) - } - public stop(): void { super.dispose() this.context.events.removeAllListeners() @@ -223,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 new file mode 100644 index 000000000..c722b2772 --- /dev/null +++ b/src/browser/setupWorker/start/createFallbackRequestListener.ts @@ -0,0 +1,67 @@ +import { + Interceptor, + BatchInterceptor, + HttpRequestEventMap, +} from '@mswjs/interceptors' +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { SetupWorkerInternalContext, StartOptions } from '../glossary' +import type { RequiredDeep } from '~/core/typeUtils' +import { handleRequest } from '~/core/utils/handleRequest' + +export function createFallbackRequestListener( + context: SetupWorkerInternalContext, + options: RequiredDeep, +): Interceptor { + const interceptor = new BatchInterceptor({ + name: 'fallback', + interceptors: [new FetchInterceptor(), new XMLHttpRequestInterceptor()], + }) + + interceptor.on('request', async ({ request, requestId }) => { + const requestCloneForLogs = request.clone() + + const response = await handleRequest( + request, + requestId, + context.requestHandlers, + options, + context.emitter, + { + onMockedResponse(_, { handler, parsedResult }) { + if (!options.quiet) { + context.emitter.once('response:mocked', ({ response }) => { + handler.log({ + request: requestCloneForLogs, + response, + parsedResult, + }) + }) + } + }, + }, + ) + + if (response) { + request.respondWith(response) + } + }) + + interceptor.on( + 'response', + ({ response, isMockedResponse, request, requestId }) => { + context.emitter.emit( + isMockedResponse ? 'response:mocked' : 'response:bypass', + { + response, + request, + requestId, + }, + ) + }, + ) + + interceptor.apply() + + return interceptor +} diff --git a/src/setupWorker/start/createFallbackStart.ts b/src/browser/setupWorker/start/createFallbackStart.ts similarity index 100% rename from src/setupWorker/start/createFallbackStart.ts rename to src/browser/setupWorker/start/createFallbackStart.ts diff --git a/src/browser/setupWorker/start/createRequestListener.ts b/src/browser/setupWorker/start/createRequestListener.ts new file mode 100644 index 000000000..b6ee1e56a --- /dev/null +++ b/src/browser/setupWorker/start/createRequestListener.ts @@ -0,0 +1,116 @@ +import { + StartOptions, + SetupWorkerInternalContext, + ServiceWorkerIncomingEventsMap, +} from '../glossary' +import { + ServiceWorkerMessage, + WorkerChannel, +} from './utils/createMessageChannel' +import { parseWorkerRequest } from '../../utils/parseWorkerRequest' +import { handleRequest } from '~/core/utils/handleRequest' +import { RequiredDeep } from '~/core/typeUtils' +import { devUtils } from '~/core/utils/internal/devUtils' +import { toResponseInit } from '~/core/utils/toResponseInit' + +export const createRequestListener = ( + context: SetupWorkerInternalContext, + options: RequiredDeep, +) => { + return async ( + event: MessageEvent, + message: ServiceWorkerMessage< + 'REQUEST', + ServiceWorkerIncomingEventsMap['REQUEST'] + >, + ) => { + const messageChannel = new WorkerChannel(event.ports[0]) + + const requestId = message.payload.id + const request = parseWorkerRequest(message.payload) + const requestCloneForLogs = request.clone() + + try { + await handleRequest( + request, + requestId, + context.requestHandlers, + options, + context.emitter, + { + onPassthroughResponse() { + messageChannel.postMessage('NOT_FOUND') + }, + 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) + + /** + * @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: responseBuffer, + }) + } + + if (!options.quiet) { + context.emitter.once('response:mocked', ({ response }) => { + handler.log({ + request: requestCloneForLogs, + response, + parsedResult, + }) + }) + } + }, + }, + ) + } catch (error) { + if (error instanceof Error) { + devUtils.error( + `Uncaught exception in the request handler for "%s %s": + +%s + +This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses`, + request.method, + request.url, + error.stack ?? error, + ) + + // Treat all other exceptions in a request handler as unintended, + // alerting that there is a problem that needs fixing. + messageChannel.postMessage('MOCK_RESPONSE', { + status: 500, + statusText: 'Request Handler Error', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: error.name, + message: error.message, + stack: error.stack, + }), + }) + } + } + } +} diff --git a/src/setupWorker/start/createResponseListener.ts b/src/browser/setupWorker/start/createResponseListener.ts similarity index 62% rename from src/setupWorker/start/createResponseListener.ts rename to src/browser/setupWorker/start/createResponseListener.ts index 6fa37ad89..7719dfe19 100644 --- a/src/setupWorker/start/createResponseListener.ts +++ b/src/browser/setupWorker/start/createResponseListener.ts @@ -1,7 +1,7 @@ import { ServiceWorkerIncomingEventsMap, SetupWorkerInternalContext, -} from '../../setupWorker/glossary' +} from '../glossary' import { ServiceWorkerMessage } from './utils/createMessageChannel' export function createResponseListener(context: SetupWorkerInternalContext) { @@ -25,13 +25,22 @@ export function createResponseListener(context: SetupWorkerInternalContext) { return } - const response = new Response(responseJson.body || null, responseJson) - const isMockedResponse = response.headers.get('x-powered-by') === 'msw' + const response = + responseJson.status === 0 + ? Response.error() + : new Response(responseJson.body, responseJson) - if (isMockedResponse) { - context.emitter.emit('response:mocked', response, responseJson.requestId) - } else { - context.emitter.emit('response:bypass', response, responseJson.requestId) - } + context.emitter.emit( + responseJson.isMockedResponse ? 'response:mocked' : 'response:bypass', + { + response, + /** + * @todo @fixme In this context, we don't know anything about + * the request. + */ + request: null as any, + requestId: responseJson.requestId, + }, + ) } } diff --git a/src/setupWorker/start/createStartHandler.ts b/src/browser/setupWorker/start/createStartHandler.ts similarity index 94% rename from src/setupWorker/start/createStartHandler.ts rename to src/browser/setupWorker/start/createStartHandler.ts index c7a127593..2ad650604 100644 --- a/src/setupWorker/start/createStartHandler.ts +++ b/src/browser/setupWorker/start/createStartHandler.ts @@ -1,13 +1,13 @@ import { until } from '@open-draft/until' +import { devUtils } from '~/core/utils/internal/devUtils' import { getWorkerInstance } from './utils/getWorkerInstance' import { enableMocking } from './utils/enableMocking' import { SetupWorkerInternalContext, StartHandler } from '../glossary' import { createRequestListener } from './createRequestListener' -import { requestIntegrityCheck } from '../../utils/internal/requestIntegrityCheck' +import { requestIntegrityCheck } from '../../utils/requestIntegrityCheck' import { deferNetworkRequestsUntil } from '../../utils/deferNetworkRequestsUntil' import { createResponseListener } from './createResponseListener' import { validateWorkerScope } from './utils/validateWorkerScope' -import { devUtils } from '../../utils/internal/devUtils' export const createStartHandler = ( context: SetupWorkerInternalContext, @@ -76,13 +76,13 @@ Please consider using a custom "serviceWorker.url" option to point to the actual }) // Check if the active Service Worker is the latest published one - const [integrityError] = await until(() => + const integrityCheckResult = await until(() => requestIntegrityCheck(context, worker), ) - if (integrityError) { + if (integrityCheckResult.error) { devUtils.error(`\ -Detected outdated Service Worker: ${integrityError.message} +Detected outdated Service Worker: ${integrityCheckResult.error.message} The mocking is still enabled, but it's highly recommended that you update your Service Worker by running: diff --git a/src/setupWorker/start/utils/createMessageChannel.ts b/src/browser/setupWorker/start/utils/createMessageChannel.ts similarity index 62% rename from src/setupWorker/start/utils/createMessageChannel.ts rename to src/browser/setupWorker/start/utils/createMessageChannel.ts index 207ee5638..210a7c3d1 100644 --- a/src/setupWorker/start/utils/createMessageChannel.ts +++ b/src/browser/setupWorker/start/utils/createMessageChannel.ts @@ -1,5 +1,5 @@ import { - SerializedResponse, + StringifiedResponse, ServiceWorkerIncomingEventsMap, } from '../../glossary' @@ -12,9 +12,11 @@ export interface ServiceWorkerMessage< } interface WorkerChannelEventsMap { - MOCK_RESPONSE: [data: SerializedResponse, body?: [ArrayBuffer]] + MOCK_RESPONSE: [ + data: StringifiedResponse, + transfer?: [ReadableStream], + ] NOT_FOUND: [] - NETWORK_ERROR: [data: { name: string; message: string }] } export class WorkerChannel { @@ -25,6 +27,13 @@ export class WorkerChannel { ...rest: WorkerChannelEventsMap[Event] ): void { const [data, transfer] = rest - this.port.postMessage({ type: event, data }, { transfer }) + this.port.postMessage( + { type: event, data }, + { + // @ts-ignore ReadableStream can be transferred + // but TypeScript doesn't acknowledge that. + transfer, + }, + ) } } diff --git a/src/setupWorker/start/utils/enableMocking.ts b/src/browser/setupWorker/start/utils/enableMocking.ts similarity index 94% rename from src/setupWorker/start/utils/enableMocking.ts rename to src/browser/setupWorker/start/utils/enableMocking.ts index 890211b3d..c0f19f314 100644 --- a/src/setupWorker/start/utils/enableMocking.ts +++ b/src/browser/setupWorker/start/utils/enableMocking.ts @@ -1,4 +1,4 @@ -import { devUtils } from '../../../utils/internal/devUtils' +import { devUtils } from '~/core/utils/internal/devUtils' import { StartOptions, SetupWorkerInternalContext } from '../../glossary' import { printStartMessage } from './printStartMessage' diff --git a/src/setupWorker/start/utils/getWorkerByRegistration.ts b/src/browser/setupWorker/start/utils/getWorkerByRegistration.ts similarity index 100% rename from src/setupWorker/start/utils/getWorkerByRegistration.ts rename to src/browser/setupWorker/start/utils/getWorkerByRegistration.ts diff --git a/src/setupWorker/start/utils/getWorkerInstance.ts b/src/browser/setupWorker/start/utils/getWorkerInstance.ts similarity index 88% rename from src/setupWorker/start/utils/getWorkerInstance.ts rename to src/browser/setupWorker/start/utils/getWorkerInstance.ts index 56949d806..05594426b 100644 --- a/src/setupWorker/start/utils/getWorkerInstance.ts +++ b/src/browser/setupWorker/start/utils/getWorkerInstance.ts @@ -1,8 +1,8 @@ import { until } from '@open-draft/until' +import { devUtils } from '~/core/utils/internal/devUtils' +import { getAbsoluteWorkerUrl } from '../../../utils/getAbsoluteWorkerUrl' import { getWorkerByRegistration } from './getWorkerByRegistration' import { ServiceWorkerInstanceTuple, FindWorker } from '../../glossary' -import { getAbsoluteWorkerUrl } from '../../../utils/url/getAbsoluteWorkerUrl' -import { devUtils } from '../../../utils/internal/devUtils' /** * Returns an active Service Worker instance. @@ -50,7 +50,7 @@ export const getWorkerInstance = async ( } // When the Service Worker wasn't found, register it anew and return the reference. - const [error, instance] = await until( + const registrationResult = await until( async () => { const registration = await navigator.serviceWorker.register(url, options) return [ @@ -63,8 +63,8 @@ export const getWorkerInstance = async ( ) // Handle Service Worker registration errors. - if (error) { - const isWorkerMissing = error.message.includes('(404)') + if (registrationResult.error) { + const isWorkerMissing = registrationResult.error.message.includes('(404)') // Produce a custom error message when given a non-existing Service Worker url. // Suggest developers to check their setup. @@ -85,10 +85,10 @@ Learn more about creating the Service Worker script: https://mswjs.io/docs/cli/i throw new Error( devUtils.formatMessage( 'Failed to register the Service Worker:\n\n%s', - error.message, + registrationResult.error.message, ), ) } - return instance + return registrationResult.data } diff --git a/src/setupWorker/start/utils/prepareStartHandler.test.ts b/src/browser/setupWorker/start/utils/prepareStartHandler.test.ts similarity index 100% rename from src/setupWorker/start/utils/prepareStartHandler.test.ts rename to src/browser/setupWorker/start/utils/prepareStartHandler.test.ts diff --git a/src/setupWorker/start/utils/prepareStartHandler.ts b/src/browser/setupWorker/start/utils/prepareStartHandler.ts similarity index 90% rename from src/setupWorker/start/utils/prepareStartHandler.ts rename to src/browser/setupWorker/start/utils/prepareStartHandler.ts index 3828a13c1..e98fe832c 100644 --- a/src/setupWorker/start/utils/prepareStartHandler.ts +++ b/src/browser/setupWorker/start/utils/prepareStartHandler.ts @@ -1,5 +1,5 @@ -import { RequiredDeep } from '../../../typeUtils' -import { mergeRight } from '../../../utils/internal/mergeRight' +import { RequiredDeep } from '~/core/typeUtils' +import { mergeRight } from '~/core/utils/internal/mergeRight' import { SetupWorker, SetupWorkerInternalContext, diff --git a/src/setupWorker/start/utils/printStartMessage.test.ts b/src/browser/setupWorker/start/utils/printStartMessage.test.ts similarity index 100% rename from src/setupWorker/start/utils/printStartMessage.test.ts rename to src/browser/setupWorker/start/utils/printStartMessage.test.ts diff --git a/src/setupWorker/start/utils/printStartMessage.ts b/src/browser/setupWorker/start/utils/printStartMessage.ts similarity index 93% rename from src/setupWorker/start/utils/printStartMessage.ts rename to src/browser/setupWorker/start/utils/printStartMessage.ts index 9e588afaa..44ffcd353 100644 --- a/src/setupWorker/start/utils/printStartMessage.ts +++ b/src/browser/setupWorker/start/utils/printStartMessage.ts @@ -1,4 +1,4 @@ -import { devUtils } from '../../../utils/internal/devUtils' +import { devUtils } from '~/core/utils/internal/devUtils' export interface PrintStartMessageArgs { quiet?: boolean diff --git a/src/setupWorker/start/utils/validateWorkerScope.ts b/src/browser/setupWorker/start/utils/validateWorkerScope.ts similarity index 91% rename from src/setupWorker/start/utils/validateWorkerScope.ts rename to src/browser/setupWorker/start/utils/validateWorkerScope.ts index b288e0d39..0e93412c2 100644 --- a/src/setupWorker/start/utils/validateWorkerScope.ts +++ b/src/browser/setupWorker/start/utils/validateWorkerScope.ts @@ -1,4 +1,4 @@ -import { devUtils } from '../../../utils/internal/devUtils' +import { devUtils } from '~/core/utils/internal/devUtils' import { StartOptions } from '../../glossary' export function validateWorkerScope( diff --git a/src/setupWorker/stop/createFallbackStop.ts b/src/browser/setupWorker/stop/createFallbackStop.ts similarity index 100% rename from src/setupWorker/stop/createFallbackStop.ts rename to src/browser/setupWorker/stop/createFallbackStop.ts diff --git a/src/setupWorker/stop/createStop.ts b/src/browser/setupWorker/stop/createStop.ts similarity index 94% rename from src/setupWorker/stop/createStop.ts rename to src/browser/setupWorker/stop/createStop.ts index df4a2e5d1..48c37996d 100644 --- a/src/setupWorker/stop/createStop.ts +++ b/src/browser/setupWorker/stop/createStop.ts @@ -1,4 +1,4 @@ -import { devUtils } from '../../utils/internal/devUtils' +import { devUtils } from '~/core/utils/internal/devUtils' import { SetupWorkerInternalContext, StopHandler } from '../glossary' import { printStopMessage } from './utils/printStopMessage' diff --git a/src/setupWorker/stop/utils/printStopMessage.test.ts b/src/browser/setupWorker/stop/utils/printStopMessage.test.ts similarity index 100% rename from src/setupWorker/stop/utils/printStopMessage.test.ts rename to src/browser/setupWorker/stop/utils/printStopMessage.test.ts diff --git a/src/setupWorker/stop/utils/printStopMessage.ts b/src/browser/setupWorker/stop/utils/printStopMessage.ts similarity index 79% rename from src/setupWorker/stop/utils/printStopMessage.ts rename to src/browser/setupWorker/stop/utils/printStopMessage.ts index d12246fea..43a08a7a3 100644 --- a/src/setupWorker/stop/utils/printStopMessage.ts +++ b/src/browser/setupWorker/stop/utils/printStopMessage.ts @@ -1,4 +1,4 @@ -import { devUtils } from '../../../utils/internal/devUtils' +import { devUtils } from '~/core/utils/internal/devUtils' export function printStopMessage(args: { quiet?: boolean } = {}): void { if (args.quiet) { 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/utils/deferNetworkRequestsUntil.test.ts b/src/browser/utils/deferNetworkRequestsUntil.test.ts similarity index 94% rename from src/utils/deferNetworkRequestsUntil.test.ts rename to src/browser/utils/deferNetworkRequestsUntil.test.ts index 8ba23be8a..c5f0e963c 100644 --- a/src/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/utils/deferNetworkRequestsUntil.ts b/src/browser/utils/deferNetworkRequestsUntil.ts similarity index 100% rename from src/utils/deferNetworkRequestsUntil.ts rename to src/browser/utils/deferNetworkRequestsUntil.ts diff --git a/src/utils/url/getAbsoluteWorkerUrl.test.ts b/src/browser/utils/getAbsoluteWorkerUrl.test.ts similarity index 100% rename from src/utils/url/getAbsoluteWorkerUrl.test.ts rename to src/browser/utils/getAbsoluteWorkerUrl.test.ts diff --git a/src/utils/url/getAbsoluteWorkerUrl.ts b/src/browser/utils/getAbsoluteWorkerUrl.ts similarity index 100% rename from src/utils/url/getAbsoluteWorkerUrl.ts rename to src/browser/utils/getAbsoluteWorkerUrl.ts diff --git a/src/browser/utils/parseWorkerRequest.ts b/src/browser/utils/parseWorkerRequest.ts new file mode 100644 index 000000000..4160efcb8 --- /dev/null +++ b/src/browser/utils/parseWorkerRequest.ts @@ -0,0 +1,15 @@ +import { pruneGetRequestBody } from './pruneGetRequestBody' +import type { ServiceWorkerIncomingRequest } from '../setupWorker/glossary' + +/** + * Converts a given request received from the Service Worker + * into a Fetch `Request` instance. + */ +export function parseWorkerRequest( + incomingRequest: ServiceWorkerIncomingRequest, +): Request { + return new Request(incomingRequest.url, { + ...incomingRequest, + body: pruneGetRequestBody(incomingRequest), + }) +} diff --git a/src/browser/utils/pruneGetRequestBody.test.ts b/src/browser/utils/pruneGetRequestBody.test.ts new file mode 100644 index 000000000..eee2932a9 --- /dev/null +++ b/src/browser/utils/pruneGetRequestBody.test.ts @@ -0,0 +1,53 @@ +/** + * @jest-environment jsdom + */ +import { TextEncoder } from 'util' +import { pruneGetRequestBody } from './pruneGetRequestBody' + +test('sets empty GET request body to undefined', () => { + expect( + pruneGetRequestBody({ + method: 'GET', + }), + ).toBeUndefined() + + expect( + pruneGetRequestBody({ + method: 'GET', + // There's no such thing as a GET request with a body. + body: new ArrayBuffer(5), + }), + ).toBeUndefined() +}) + +test('sets HEAD request body to undefined', () => { + expect( + pruneGetRequestBody({ + method: 'HEAD', + }), + ).toBeUndefined() + + expect( + pruneGetRequestBody({ + method: 'HEAD', + body: new ArrayBuffer(5), + }), + ).toBeUndefined() +}) + +test('ignores requests of the other methods than GET', () => { + const body = new TextEncoder().encode('hello world') + expect( + pruneGetRequestBody({ + method: 'POST', + body, + }), + ).toEqual(body) + + expect( + pruneGetRequestBody({ + method: 'PUT', + body, + }), + ).toEqual(body) +}) diff --git a/src/browser/utils/pruneGetRequestBody.ts b/src/browser/utils/pruneGetRequestBody.ts new file mode 100644 index 000000000..b17602217 --- /dev/null +++ b/src/browser/utils/pruneGetRequestBody.ts @@ -0,0 +1,21 @@ +import type { ServiceWorkerIncomingRequest } from '../setupWorker/glossary' + +type Input = Pick + +/** + * Ensures that an empty GET request body is always represented as `undefined`. + */ +export function pruneGetRequestBody( + request: Input, +): ServiceWorkerIncomingRequest['body'] { + // Force HEAD/GET request body to always be empty. + // The worker reads any request's body as ArrayBuffer, + // and you cannot re-construct a GET/HEAD Request + // with an ArrayBuffer, even if empty. Also note that + // "request.body" is always undefined in the worker. + if (['HEAD', 'GET'].includes(request.method)) { + return undefined + } + + return request.body +} diff --git a/src/utils/internal/requestIntegrityCheck.ts b/src/browser/utils/requestIntegrityCheck.ts similarity index 90% rename from src/utils/internal/requestIntegrityCheck.ts rename to src/browser/utils/requestIntegrityCheck.ts index 10f1fb112..67e1b4144 100644 --- a/src/utils/internal/requestIntegrityCheck.ts +++ b/src/browser/utils/requestIntegrityCheck.ts @@ -1,4 +1,4 @@ -import { SetupWorkerInternalContext } from '../../setupWorker/glossary' +import type { SetupWorkerInternalContext } from '../setupWorker/glossary' export async function requestIntegrityCheck( context: SetupWorkerInternalContext, 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/context/body.test.ts b/src/context/body.test.ts deleted file mode 100644 index c6e7056a9..000000000 --- a/src/context/body.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { body } from './body' -import { set } from './set' -import { response } from '../response' - -test('sets a given body value without implicit "Content-Type" header', async () => { - const result = await response(body('Lorem ipsum')) - - expect(result).toHaveProperty('body', 'Lorem ipsum') - expect(result.headers.get('content-type')).toBeNull() -}) - -test('does not stringify raw body twice if content is string and "Content-Type" header is "json"', async () => { - const result = await response( - set('Content-Type', 'application/json'), - body(JSON.stringify('some text')), - ) - - expect(result).toHaveProperty('body', `"some text"`) -}) diff --git a/src/context/body.ts b/src/context/body.ts deleted file mode 100644 index 4a07a62ec..000000000 --- a/src/context/body.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ResponseTransformer } from '../response' - -/** - * Sets a raw response body. Does not append any `Content-Type` headers. - * @example - * res(ctx.body('Successful response')) - * res(ctx.body(JSON.stringify({ key: 'value' }))) - * @see {@link https://mswjs.io/docs/api/context/body `ctx.body()`} - */ -export const body = < - BodyType extends string | Blob | BufferSource | ReadableStream | FormData, ->( - value: BodyType, -): ResponseTransformer => { - return (res) => { - res.body = value - return res - } -} diff --git a/src/context/cookie.node.test.ts b/src/context/cookie.node.test.ts deleted file mode 100644 index 8551b706a..000000000 --- a/src/context/cookie.node.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @jest-environment node - */ -import { cookie } from './cookie' -import { response } from '../response' - -test('sets a cookie on the response headers, node environment', async () => { - const result = await response(cookie('my-cookie', 'arbitrary-value')) - expect(result.headers.get('set-cookie')).toEqual('my-cookie=arbitrary-value') -}) diff --git a/src/context/cookie.test.ts b/src/context/cookie.test.ts deleted file mode 100644 index c17cca610..000000000 --- a/src/context/cookie.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * @jest-environment jsdom - */ -import * as cookieUtils from 'cookie' -import { cookie } from './cookie' -import { response } from '../response' -import { clearCookies } from '../../test/support/utils' - -beforeAll(() => { - clearCookies() -}) - -afterEach(() => { - clearCookies() -}) - -test('sets a given response cookie', async () => { - const result = await response(cookie('myCookie', 'value')) - - expect(result.headers.get('set-cookie')).toBe('myCookie=value') - - // Propagates the response cookies on the document. - const allCookies = cookieUtils.parse(document.cookie) - expect(allCookies).toEqual({ myCookie: 'value' }) -}) - -test('supports setting multiple response cookies', async () => { - const result = await response( - cookie('firstCookie', 'yes'), - cookie('secondCookie', 'no'), - ) - - expect(result.headers.get('set-cookie')).toBe( - 'secondCookie=no, firstCookie=yes', - ) - - const allCookies = cookieUtils.parse(document.cookie) - expect(allCookies).toEqual({ firstCookie: 'yes', secondCookie: 'no' }) -}) diff --git a/src/context/cookie.ts b/src/context/cookie.ts deleted file mode 100644 index 8d38b270a..000000000 --- a/src/context/cookie.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as cookieUtils from 'cookie' -import { ResponseTransformer } from '../response' - -/** - * Sets a given cookie on the mocked response. - * @example res(ctx.cookie('name', 'value')) - */ -export const cookie = ( - name: string, - value: string, - options?: cookieUtils.CookieSerializeOptions, -): ResponseTransformer => { - return (res) => { - const serializedCookie = cookieUtils.serialize(name, value, options) - res.headers.append('Set-Cookie', serializedCookie) - - if (typeof document !== 'undefined') { - document.cookie = serializedCookie - } - - return res - } -} diff --git a/src/context/data.test.ts b/src/context/data.test.ts deleted file mode 100644 index f73e0693c..000000000 --- a/src/context/data.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { data } from './data' -import { errors } from './errors' -import { response } from '../response' - -test('sets a single data on the response JSON body', async () => { - const result = await response(data({ name: 'msw' })) - - expect(result.headers.get('content-type')).toBe('application/json') - expect(result).toHaveProperty( - 'body', - JSON.stringify({ - data: { - name: 'msw', - }, - }), - ) -}) - -test('sets multiple data on the response JSON body', async () => { - const result = await response( - data({ name: 'msw' }), - data({ description: 'API mocking library' }), - ) - - expect(result.headers.get('content-type')).toBe('application/json') - expect(result).toHaveProperty( - 'body', - JSON.stringify({ - data: { - description: 'API mocking library', - name: 'msw', - }, - }), - ) -}) - -test('combines with error in the response JSON body', async () => { - const result = await response( - data({ name: 'msw' }), - errors([ - { - message: 'exceeds the limit of awesomeness', - }, - ]), - ) - - expect(result.headers.get('content-type')).toBe('application/json') - expect(result).toHaveProperty( - 'body', - JSON.stringify({ - errors: [ - { - message: 'exceeds the limit of awesomeness', - }, - ], - data: { - name: 'msw', - }, - }), - ) -}) diff --git a/src/context/data.ts b/src/context/data.ts deleted file mode 100644 index 9180cb7d9..000000000 --- a/src/context/data.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { jsonParse } from '../utils/internal/jsonParse' -import { mergeRight } from '../utils/internal/mergeRight' -import { json } from './json' -import { GraphQLPayloadContext } from '../typeUtils' - -/** - * Sets a given payload as a GraphQL response body. - * @example - * res(ctx.data({ user: { firstName: 'John' }})) - * @see {@link https://mswjs.io/docs/api/context/data `ctx.data()`} - */ -export const data: GraphQLPayloadContext> = ( - payload, -) => { - return (res) => { - const prevBody = jsonParse(res.body) || {} - const nextBody = mergeRight(prevBody, { data: payload }) - - return json(nextBody)(res) - } -} diff --git a/src/context/delay.node.test.ts b/src/context/delay.node.test.ts deleted file mode 100644 index 09d78a465..000000000 --- a/src/context/delay.node.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @jest-environment node - */ -import { delay, NODE_SERVER_RESPONSE_TIME } from './delay' -import { response } from '../response' - -test('sets a Node.js-specific response delay when not provided', async () => { - const resolvedResponse = await response(delay()) - expect(resolvedResponse).toHaveProperty('delay', NODE_SERVER_RESPONSE_TIME) -}) - -test('allows response delay duration overrides', async () => { - const resolvedResponse = await response(delay(1234)) - expect(resolvedResponse).toHaveProperty('delay', 1234) -}) - -test('throws an exception given a too large duration', async () => { - const createErrorMessage = (value: any) => { - return `Failed to delay a response: provided delay duration (${value}) exceeds the maximum allowed duration for "setTimeout" (2147483647). This will cause the response to be returned immediately. Please use a number within the allowed range to delay the response by exact duration, or consider the "infinite" delay mode to delay the response indefinitely.` - } - - const exceedingValues = [ - Infinity, - Number.MAX_VALUE, - Number.MAX_SAFE_INTEGER, - 2147483648, - ] - - for (const value of exceedingValues) { - await expect(() => response(delay(value))).rejects.toThrow( - createErrorMessage(value), - ) - } -}) - -test('throws an exception given an unknown delay mode', async () => { - await expect(() => response(delay('foo' as any))).rejects.toThrow( - 'Failed to delay a response: unknown delay mode "foo". Please make sure you provide one of the supported modes ("real", "infinite") or a number to "ctx.delay".', - ) -}) diff --git a/src/context/delay.test.ts b/src/context/delay.test.ts deleted file mode 100644 index e3ea29852..000000000 --- a/src/context/delay.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @jest-environment jsdom - * - * Since jsdom also runs in Node.js, expect a Node.js-specific implicit delay. - */ -import { delay, NODE_SERVER_RESPONSE_TIME } from './delay' -import { response } from '../response' - -test('sets a Node.js-specific response delay when not provided', async () => { - const resolvedResponse = await response(delay()) - expect(resolvedResponse).toHaveProperty('delay', NODE_SERVER_RESPONSE_TIME) -}) - -test('allows response delay duration overrides', async () => { - const resolvedResponse = await response(delay(1234)) - expect(resolvedResponse).toHaveProperty('delay', 1234) -}) - -test('throws an exception given a too large duration', async () => { - const createErrorMessage = (value: any) => { - return `Failed to delay a response: provided delay duration (${value}) exceeds the maximum allowed duration for "setTimeout" (2147483647). This will cause the response to be returned immediately. Please use a number within the allowed range to delay the response by exact duration, or consider the "infinite" delay mode to delay the response indefinitely.` - } - - const exceedingValues = [ - Infinity, - Number.MAX_VALUE, - Number.MAX_SAFE_INTEGER, - 2147483648, - ] - - for (const value of exceedingValues) { - await expect(() => response(delay(value))).rejects.toThrow( - createErrorMessage(value), - ) - } -}) - -test('throws an exception given an unknown delay mode', async () => { - await expect(() => response(delay('foo' as any))).rejects.toThrow( - 'Failed to delay a response: unknown delay mode "foo". Please make sure you provide one of the supported modes ("real", "infinite") or a number to "ctx.delay".', - ) -}) diff --git a/src/context/delay.ts b/src/context/delay.ts deleted file mode 100644 index 50ff9b01a..000000000 --- a/src/context/delay.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { isNodeProcess } from 'is-node-process' -import { ResponseTransformer } from '../response' - -export const SET_TIMEOUT_MAX_ALLOWED_INT = 2147483647 -export const MIN_SERVER_RESPONSE_TIME = 100 -export const MAX_SERVER_RESPONSE_TIME = 400 -export const NODE_SERVER_RESPONSE_TIME = 5 - -const getRandomServerResponseTime = () => { - if (isNodeProcess()) { - return NODE_SERVER_RESPONSE_TIME - } - - return Math.floor( - Math.random() * (MAX_SERVER_RESPONSE_TIME - MIN_SERVER_RESPONSE_TIME) + - MIN_SERVER_RESPONSE_TIME, - ) -} - -export type DelayMode = 'real' | 'infinite' - -/** - * Delays the response by the given duration (ms). - * @example - * res(ctx.delay(1200)) // delay response by 1200ms - * res(ctx.delay()) // emulate realistic server response time - * res(ctx.delay('infinite')) // delay response infinitely - * @see {@link https://mswjs.io/docs/api/context/delay `ctx.delay()`} - */ -export const delay = ( - durationOrMode?: DelayMode | number, -): ResponseTransformer => { - return (res) => { - let delayTime: number - - if (typeof durationOrMode === 'string') { - switch (durationOrMode) { - case 'infinite': { - // Using `Infinity` as a delay value executes the response timeout immediately. - // Instead, use the maximum allowed integer for `setTimeout`. - delayTime = SET_TIMEOUT_MAX_ALLOWED_INT - break - } - case 'real': { - delayTime = getRandomServerResponseTime() - break - } - default: { - throw new Error( - `Failed to delay a response: unknown delay mode "${durationOrMode}". Please make sure you provide one of the supported modes ("real", "infinite") or a number to "ctx.delay".`, - ) - } - } - } else if (typeof durationOrMode === 'undefined') { - // Use random realistic server response time when no explicit delay duration was provided. - delayTime = getRandomServerResponseTime() - } else { - // Guard against passing values like `Infinity` or `Number.MAX_VALUE` - // as the response delay duration. They don't produce the result you may expect. - if (durationOrMode > SET_TIMEOUT_MAX_ALLOWED_INT) { - throw new Error( - `Failed to delay a response: provided delay duration (${durationOrMode}) exceeds the maximum allowed duration for "setTimeout" (${SET_TIMEOUT_MAX_ALLOWED_INT}). This will cause the response to be returned immediately. Please use a number within the allowed range to delay the response by exact duration, or consider the "infinite" delay mode to delay the response indefinitely.`, - ) - } - - delayTime = durationOrMode - } - - res.delay = delayTime - return res - } -} diff --git a/src/context/errors.test.ts b/src/context/errors.test.ts deleted file mode 100644 index ed1cd1f1f..000000000 --- a/src/context/errors.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { errors } from './errors' -import { data } from './data' -import { response } from '../response' - -test('sets a given error on the response JSON body', async () => { - const result = await response(errors([{ message: 'Error message' }])) - - expect(result.headers.get('content-type')).toEqual('application/json') - expect(result).toHaveProperty( - 'body', - JSON.stringify({ - errors: [ - { - message: 'Error message', - }, - ], - }), - ) -}) - -test('sets given errors on the response JSON body', async () => { - const result = await response( - errors([{ message: 'Error message' }, { message: 'Second error' }]), - ) - - expect(result.headers.get('content-type')).toEqual('application/json') - expect(result).toHaveProperty( - 'body', - JSON.stringify({ - errors: [ - { - message: 'Error message', - }, - { - message: 'Second error', - }, - ], - }), - ) -}) - -test('combines with data in the response JSON body', async () => { - const result = await response( - data({ name: 'msw' }), - errors([{ message: 'exceeds the limit of awesomeness' }]), - ) - - expect(result.headers.get('content-type')).toEqual('application/json') - expect(result).toHaveProperty( - 'body', - JSON.stringify({ - errors: [ - { - message: 'exceeds the limit of awesomeness', - }, - ], - data: { - name: 'msw', - }, - }), - ) -}) - -test('bypasses undefined errors', async () => { - const result = await response(errors(undefined), errors(null)) - - expect(result.headers.get('content-type')).not.toEqual('application/json') - expect(result).toHaveProperty('body', null) -}) diff --git a/src/context/errors.ts b/src/context/errors.ts deleted file mode 100644 index c64d97331..000000000 --- a/src/context/errors.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { GraphQLError } from 'graphql' -import { ResponseTransformer } from '../response' -import { jsonParse } from '../utils/internal/jsonParse' -import { mergeRight } from '../utils/internal/mergeRight' -import { json } from './json' - -/** - * Sets a given list of GraphQL errors on the mocked response. - * @example res(ctx.errors([{ message: 'Unauthorized' }])) - * @see {@link https://mswjs.io/docs/api/context/errors} - */ -export const errors = < - ErrorsType extends readonly Partial[] | null | undefined, ->( - errorsList: ErrorsType, -): ResponseTransformer => { - return (res) => { - if (errorsList == null) { - return res - } - - const prevBody = jsonParse(res.body) || {} - const nextBody = mergeRight(prevBody, { errors: errorsList }) - - return json(nextBody)(res as any) as any - } -} diff --git a/src/context/extensions.test.ts b/src/context/extensions.test.ts deleted file mode 100644 index b341e1598..000000000 --- a/src/context/extensions.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { errors } from './errors' -import { data } from './data' -import { extensions } from './extensions' -import { response } from '../response' - -test('sets standalone extensions on the response JSON body', async () => { - const result = await response(extensions({ tracking: { version: 1 } })) - - expect(result.headers.get('content-type')).toEqual('application/json') - expect(result.body).toEqual( - JSON.stringify({ - extensions: { - tracking: { - version: 1, - }, - }, - }), - ) -}) - -test('sets given extensions on the response JSON body with data', async () => { - const result = await response( - data({ hello: 'world' }), - extensions({ tracking: { version: 1 } }), - ) - - expect(result.headers.get('content-type')).toEqual('application/json') - expect(result.body).toEqual( - JSON.stringify({ - extensions: { - tracking: { - version: 1, - }, - }, - data: { - hello: 'world', - }, - }), - ) -}) - -test('sets given extensions on the response JSON body in the presence with data and errors', async () => { - const result = await response( - data({ hello: 'world' }), - extensions({ tracking: { version: 1 } }), - errors([{ message: 'Error message' }]), - ) - - expect(result.headers.get('content-type')).toEqual('application/json') - expect(result.body).toEqual( - JSON.stringify({ - errors: [ - { - message: 'Error message', - }, - ], - extensions: { - tracking: { - version: 1, - }, - }, - data: { - hello: 'world', - }, - }), - ) -}) diff --git a/src/context/extensions.ts b/src/context/extensions.ts deleted file mode 100644 index 4c2925394..000000000 --- a/src/context/extensions.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { jsonParse } from '../utils/internal/jsonParse' -import { mergeRight } from '../utils/internal/mergeRight' -import { json } from './json' -import { GraphQLPayloadContext } from '../typeUtils' - -/** - * Sets the GraphQL extensions on a given response. - * @example - * res(ctx.extensions({ tracing: { version: 1 }})) - * @see {@link https://mswjs.io/docs/api/context/extensions `ctx.extensions()`} - */ -export const extensions: GraphQLPayloadContext> = ( - payload, -) => { - return (res) => { - const prevBody = jsonParse(res.body) || {} - const nextBody = mergeRight(prevBody, { extensions: payload }) - return json(nextBody)(res) - } -} diff --git a/src/context/fetch.test.ts b/src/context/fetch.test.ts deleted file mode 100644 index 01c1ae13a..000000000 --- a/src/context/fetch.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { augmentRequestInit } from './fetch' - -test('augments RequestInit with the Headers instance', () => { - const result = augmentRequestInit({ - headers: new Headers({ Authorization: 'token' }), - }) - const headers = new Headers(result.headers) - - expect(headers.get('Authorization')).toEqual('token') - expect(headers.get('x-msw-bypass')).toEqual('true') -}) - -test('augments RequestInit with the string[][] headers object', () => { - const result = augmentRequestInit({ - headers: [['Authorization', 'token']], - }) - const headers = new Headers(result.headers) - - expect(headers.get('x-msw-bypass')).toEqual('true') - expect(headers.get('authorization')).toEqual('token') -}) - -test('aguments RequestInit with the Record headers', () => { - const result = augmentRequestInit({ - headers: { - Authorization: 'token', - }, - }) - const headers = new Headers(result.headers) - - expect(headers.get('x-msw-bypass')).toEqual('true') - expect(headers.get('authorization')).toEqual('token') -}) diff --git a/src/context/fetch.ts b/src/context/fetch.ts deleted file mode 100644 index 6d91a756d..000000000 --- a/src/context/fetch.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { isNodeProcess } from 'is-node-process' -import { Headers } from 'headers-polyfill' -import { MockedRequest } from '../utils/request/MockedRequest' - -const useFetch: (input: RequestInfo, init?: RequestInit) => Promise = - isNodeProcess() - ? (input, init) => - import('node-fetch').then(({ default: nodeFetch }) => - (nodeFetch as unknown as typeof window.fetch)(input, init), - ) - : globalThis.fetch - -export const augmentRequestInit = (requestInit: RequestInit): RequestInit => { - const headers = new Headers(requestInit.headers) - headers.set('x-msw-bypass', 'true') - - return { - ...requestInit, - headers: headers.all(), - } -} - -const createFetchRequestParameters = (input: MockedRequest): RequestInit => { - const { body, method } = input - const requestParameters: RequestInit = { - ...input, - body: undefined, - } - - if (['GET', 'HEAD'].includes(method)) { - return requestParameters - } - - if ( - typeof body === 'object' || - typeof body === 'number' || - typeof body === 'boolean' - ) { - requestParameters.body = JSON.stringify(body) - } else { - requestParameters.body = body - } - - return requestParameters -} - -/** - * Performs a bypassed request inside a request handler. - * @example - * const originalResponse = await ctx.fetch(req) - * @see {@link https://mswjs.io/docs/api/context/fetch `ctx.fetch()`} - */ -export const fetch = ( - input: string | MockedRequest, - requestInit: RequestInit = {}, -): Promise => { - if (typeof input === 'string') { - return useFetch(input, augmentRequestInit(requestInit)) - } - - const requestParameters = createFetchRequestParameters(input) - const derivedRequestInit = augmentRequestInit(requestParameters) - - return useFetch(input.url.href, derivedRequestInit) -} diff --git a/src/context/field.test.ts b/src/context/field.test.ts deleted file mode 100644 index 48952e9d0..000000000 --- a/src/context/field.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { field } from './field' -import { response } from '../response' -import { data } from './data' -import { errors } from './errors' - -test('sets a given field value string on the response JSON body', async () => { - const result = await response(field('field', 'value')) - - expect(result.headers.get('content-type')).toBe('application/json') - expect(result).toHaveProperty('body', JSON.stringify({ field: 'value' })) -}) - -test('sets a given field value object on the response JSON body', async () => { - const result = await response( - field('metadata', { - date: new Date('2022-05-27'), - comment: 'nice metadata', - }), - ) - - expect(result.headers.get('content-type')).toBe('application/json') - expect(result).toHaveProperty( - 'body', - JSON.stringify({ - metadata: { date: new Date('2022-05-27'), comment: 'nice metadata' }, - }), - ) -}) - -test('combines with data, errors and other field in the response JSON body', async () => { - const result = await response( - data({ name: 'msw' }), - errors([{ message: 'exceeds the limit of awesomeness' }]), - field('field', { errorCode: 'value' }), - field('field2', 123), - ) - - expect(result.headers.get('content-type')).toEqual('application/json') - expect(result).toHaveProperty( - 'body', - JSON.stringify({ - field2: 123, - field: { errorCode: 'value' }, - errors: [ - { - message: 'exceeds the limit of awesomeness', - }, - ], - data: { - name: 'msw', - }, - }), - ) -}) - -test('throws when trying to set non-serializable values', async () => { - await expect(response(field('metadata', BigInt(1)))).rejects.toThrow( - 'Do not know how to serialize a BigInt', - ) -}) - -test('throws when passing an empty string as field name', async () => { - await expect(response(field('' as string, 'value'))).rejects.toThrow( - `[MSW] Failed to set a custom field on a GraphQL response: field name cannot be empty.`, - ) -}) - -test('throws when passing an empty string (when trimmed) as field name', async () => { - await expect(response(field(' ' as string, 'value'))).rejects.toThrow( - `[MSW] Failed to set a custom field on a GraphQL response: field name cannot be empty.`, - ) -}) - -test('throws when using "data" as the field name', async () => { - await expect( - response( - field( - // @ts-expect-error Test runtime value. - 'data', - 'value', - ), - ), - ).rejects.toThrow( - '[MSW] Failed to set a custom "data" field on a mocked GraphQL response: forbidden field name. Did you mean to call "ctx.data()" instead?', - ) -}) - -test('throws when using "errors" as the field name', async () => { - await expect( - response( - field( - // @ts-expect-error Test runtime value. - 'errors', - 'value', - ), - ), - ).rejects.toThrow( - '[MSW] Failed to set a custom "errors" field on a mocked GraphQL response: forbidden field name. Did you mean to call "ctx.errors()" instead?', - ) -}) - -test('throws when using "extensions" as the field name', async () => { - await expect( - response( - field( - // @ts-expect-error Test runtime value. - 'extensions', - 'value', - ), - ), - ).rejects.toThrow( - '[MSW] Failed to set a custom "extensions" field on a mocked GraphQL response: forbidden field name. Did you mean to call "ctx.extensions()" instead?', - ) -}) diff --git a/src/context/field.ts b/src/context/field.ts deleted file mode 100644 index a97764607..000000000 --- a/src/context/field.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { invariant } from 'outvariant' -import { ResponseTransformer } from '../response' -import { devUtils } from '../utils/internal/devUtils' -import { jsonParse } from '../utils/internal/jsonParse' -import { mergeRight } from '../utils/internal/mergeRight' -import { json } from './json' - -type ForbiddenFieldNames = '' | 'data' | 'errors' | 'extensions' - -/** - * Set a custom field on the GraphQL mocked response. - * @example res(ctx.fields('customField', value)) - * @see {@link https://mswjs.io/docs/api/context/field} - */ -export const field = ( - fieldName: FieldNameType extends ForbiddenFieldNames ? never : FieldNameType, - fieldValue: FieldValueType, -): ResponseTransformer => { - return (res) => { - validateFieldName(fieldName) - - const prevBody = jsonParse(res.body) || {} - const nextBody = mergeRight(prevBody, { [fieldName]: fieldValue }) - - return json(nextBody)(res as any) as any - } -} - -function validateFieldName(fieldName: string) { - invariant( - fieldName.trim() !== '', - devUtils.formatMessage( - 'Failed to set a custom field on a GraphQL response: field name cannot be empty.', - ), - ) - - invariant( - fieldName !== 'data', - devUtils.formatMessage( - 'Failed to set a custom "%s" field on a mocked GraphQL response: forbidden field name. Did you mean to call "ctx.data()" instead?', - fieldName, - ), - ) - - invariant( - fieldName !== 'errors', - devUtils.formatMessage( - 'Failed to set a custom "%s" field on a mocked GraphQL response: forbidden field name. Did you mean to call "ctx.errors()" instead?', - fieldName, - ), - ) - - invariant( - fieldName !== 'extensions', - devUtils.formatMessage( - 'Failed to set a custom "%s" field on a mocked GraphQL response: forbidden field name. Did you mean to call "ctx.extensions()" instead?', - fieldName, - ), - ) -} diff --git a/src/context/index.ts b/src/context/index.ts deleted file mode 100644 index 6cbed148d..000000000 --- a/src/context/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { status } from './status' -export { set } from './set' -export { cookie } from './cookie' -export { body } from './body' -export { data } from './data' -export { extensions } from './extensions' -export { delay } from './delay' -export { errors } from './errors' -export { fetch } from './fetch' -export { json } from './json' -export { text } from './text' -export { xml } from './xml' diff --git a/src/context/json.test.ts b/src/context/json.test.ts deleted file mode 100644 index 4c0f2b3cf..000000000 --- a/src/context/json.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { json } from './json' -import { response } from '../response' - -test('sets response content type and body to the given JSON', async () => { - const result = await response(json({ firstName: 'John' })) - expect(result.headers.get('content-type')).toEqual('application/json') - expect(result).toHaveProperty('body', `{"firstName":"John"}`) -}) - -test('sets given Array as the response JSOn body', async () => { - const result = await response(json([1, '2', true, { ok: true }, ''])) - expect(result).toHaveProperty('body', `[1,"2",true,{"ok":true},""]`) -}) - -test('sets given string as the response JSON body', async () => { - const result = await response(json('some string')) - expect(result).toHaveProperty('body', `"some string"`) -}) - -test('sets given boolean as the response JSON body', async () => { - const result = await response(json(true)) - expect(result).toHaveProperty('body', `true`) -}) - -test('sets given date as the response JSON body', async () => { - const result = await response(json(new Date(Date.UTC(2020, 0, 1)))) - expect(result).toHaveProperty('body', `"2020-01-01T00:00:00.000Z"`) -}) diff --git a/src/context/json.ts b/src/context/json.ts deleted file mode 100644 index ae67fd45d..000000000 --- a/src/context/json.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ResponseTransformer } from '../response' - -/** - * Sets the given value as the JSON body of the response. - * Appends a `Content-Type: application/json` header on the - * mocked response. - * @example - * res(ctx.json('Some string')) - * res(ctx.json({ key: 'value' })) - * res(ctx.json([1, '2', false, { ok: true }])) - * @see {@link https://mswjs.io/docs/api/context/json `ctx.json()`} - */ -export const json = ( - body: BodyTypeJSON, -): ResponseTransformer => { - return (res) => { - res.headers.set('Content-Type', 'application/json') - res.body = JSON.stringify(body) as any - - return res - } -} diff --git a/src/context/set.test.ts b/src/context/set.test.ts deleted file mode 100644 index fa061f4f1..000000000 --- a/src/context/set.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { set } from './set' -import { response } from '../response' - -test('sets a single header', async () => { - const { headers } = await response(set('Content-Type', 'image/*')) - expect(headers.get('content-type')).toEqual('image/*') -}) - -test('sets a single header with multiple values', async () => { - const { headers } = await response( - set({ - Accept: ['application/json', 'image/png'], - }), - ) - - expect(headers.get('accept')).toEqual('application/json, image/png') -}) - -test('sets multiple headers', async () => { - const { headers } = await response( - set({ - Accept: '*/*', - 'Accept-Language': 'en', - 'Content-Type': 'application/json', - }), - ) - - expect(headers.get('accept')).toEqual('*/*') - expect(headers.get('accept-language')).toEqual('en') - expect(headers.get('content-type')).toEqual('application/json') -}) diff --git a/src/context/set.ts b/src/context/set.ts deleted file mode 100644 index 71ca89c52..000000000 --- a/src/context/set.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { objectToHeaders } from 'headers-polyfill' -import { ResponseTransformer } from '../response' - -export type HeadersObject = Record< - KeyType, - string | string[] -> - -/** - * @see https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name - */ -export type ForbiddenHeaderNames = - | 'cookie' - | 'cookie2' - | 'set-cookie' - | 'set-cookie2' - -export type ForbiddenHeaderError = - `SafeResponseHeader: the '${HeaderName}' header cannot be set on the response. Please use the 'ctx.cookie()' function instead.` - -/** - * Sets one or multiple response headers. - * @example - * ctx.set('Content-Type', 'text/plain') - * ctx.set({ - * 'Accept': 'application/javascript', - * 'Content-Type': "text/plain" - * }) - * @see {@link https://mswjs.io/docs/api/context/set `ctx.set()`} - */ -export function set( - ...args: N extends string - ? Lowercase extends ForbiddenHeaderNames - ? [ForbiddenHeaderError] - : [N, string] - : N extends HeadersObject - ? Lowercase extends ForbiddenHeaderNames - ? [ForbiddenHeaderError] - : [N] - : [N] -): ResponseTransformer { - return (res) => { - const [name, value] = args - - if (typeof name === 'string') { - res.headers.append(name, value as string) - } else { - const headers = objectToHeaders(name) - headers.forEach((value, name) => { - res.headers.append(name, value) - }) - } - - return res - } -} diff --git a/src/context/status.test.ts b/src/context/status.test.ts deleted file mode 100644 index 2bd795536..000000000 --- a/src/context/status.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { status } from './status' -import { response } from '../response' - -test('sets given status code on the response', async () => { - const result = await response(status(403)) - expect(result).toHaveProperty('status', 403) - expect(result).toHaveProperty('statusText', 'Forbidden') -}) - -test('supports custom status text', async () => { - const result = await response(status(301, 'Custom text')) - expect(result).toHaveProperty('status', 301) - expect(result).toHaveProperty('statusText', 'Custom text') -}) diff --git a/src/context/status.ts b/src/context/status.ts deleted file mode 100644 index e8113519c..000000000 --- a/src/context/status.ts +++ /dev/null @@ -1,22 +0,0 @@ -import statuses from 'statuses/codes.json' -import { ResponseTransformer } from '../response' - -/** - * Sets a response status code and text. - * @example - * res(ctx.status(301)) - * res(ctx.status(400, 'Custom status text')) - * @see {@link https://mswjs.io/docs/api/context/status `ctx.status()`} - */ -export const status = ( - statusCode: number, - statusText?: string, -): ResponseTransformer => { - return (res) => { - res.status = statusCode - res.statusText = - statusText || statuses[String(statusCode) as keyof typeof statuses] - - return res - } -} diff --git a/src/context/text.test.ts b/src/context/text.test.ts deleted file mode 100644 index aafc263b1..000000000 --- a/src/context/text.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { text } from './text' -import { response } from '../response' - -test('sets a given text as the response body', async () => { - const result = await response(text('Lorem ipsum')) - - expect(result.headers.get('content-type')).toEqual('text/plain') - expect(result).toHaveProperty('body', 'Lorem ipsum') -}) diff --git a/src/context/text.ts b/src/context/text.ts deleted file mode 100644 index 6cdbb2d1d..000000000 --- a/src/context/text.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ResponseTransformer } from '../response' - -/** - * Sets a textual response body. Appends a `Content-Type: text/plain` - * header on the mocked response. - * @example res(ctx.text('Successful response')) - * @see {@link https://mswjs.io/docs/api/context/text `ctx.text()`} - */ -export const text = ( - body: BodyType, -): ResponseTransformer => { - return (res) => { - res.headers.set('Content-Type', 'text/plain') - res.body = body - return res - } -} diff --git a/src/context/xml.test.ts b/src/context/xml.test.ts deleted file mode 100644 index 479e24817..000000000 --- a/src/context/xml.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { xml } from './xml' -import { response } from '../response' - -test('sets a given XML as the response body', async () => { - const result = await response(xml('JohnJohnContent')) - * @see {@link https://mswjs.io/docs/api/context/xml `ctx.xml()`} - */ -export const xml = ( - body: BodyType, -): ResponseTransformer => { - return (res) => { - res.headers.set('Content-Type', 'text/xml') - res.body = body - return res - } -} diff --git a/src/core/HttpResponse.test.ts b/src/core/HttpResponse.test.ts new file mode 100644 index 000000000..ef9ec2df7 --- /dev/null +++ b/src/core/HttpResponse.test.ts @@ -0,0 +1,65 @@ +/** + * @jest-environment node + */ +import { TextEncoder } from 'util' +import { HttpResponse } from './HttpResponse' + +it('creates a plain response', async () => { + const response = new HttpResponse(null, { status: 301 }) + expect(response.status).toBe(301) + expect(response.statusText).toBe('Moved Permanently') + expect(response.body).toBe(null) + expect(await response.text()).toBe('') +}) + +it('creates a text response', async () => { + const response = HttpResponse.text('hello world', { status: 201 }) + + expect(response.status).toBe(201) + expect(response.statusText).toBe('Created') + expect(response.body).toBeInstanceOf(ReadableStream) + expect(await response.text()).toBe('hello world') +}) + +it('creates a json response', async () => { + const response = HttpResponse.json({ firstName: 'John' }) + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.body).toBeInstanceOf(ReadableStream) + expect(await response.json()).toEqual({ firstName: 'John' }) +}) + +it('creates an xml response', async () => { + const response = HttpResponse.xml('') + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.body).toBeInstanceOf(ReadableStream) + expect(await response.text()).toBe('') +}) + +it('creates an array buffer response', async () => { + const buffer = new TextEncoder().encode('hello world') + const response = HttpResponse.arrayBuffer(buffer) + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.body).toBeInstanceOf(ReadableStream) + + const responseData = await response.arrayBuffer() + expect(responseData).toEqual(buffer.buffer) +}) + +it('creates a form data response', async () => { + const formData = new FormData() + formData.append('firstName', 'John') + const response = HttpResponse.formData(formData) + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.body).toBeInstanceOf(ReadableStream) + + const responseData = await response.formData() + expect(responseData.get('firstName')).toBe('John') +}) diff --git a/src/core/HttpResponse.ts b/src/core/HttpResponse.ts new file mode 100644 index 000000000..e5e51c482 --- /dev/null +++ b/src/core/HttpResponse.ts @@ -0,0 +1,122 @@ +import type { DefaultBodyType } from './handlers/RequestHandler' +import { + decorateResponse, + normalizeResponseInit, +} from './utils/HttpResponse/decorators' + +export interface HttpResponseInit extends ResponseInit { + type?: ResponseType +} + +declare const bodyType: unique symbol + +export interface StrictRequest + extends Request { + json(): Promise +} + +/** + * Opaque `Response` type that supports strict body type. + */ +export interface StrictResponse + extends Response { + readonly [bodyType]: BodyType +} + +/** + * 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) { + const responseInit = normalizeResponseInit(init) + super(body, responseInit) + decorateResponse(this, responseInit) + } + + /** + * Create a `Response` with a `Content-Type: "text/plain"` body. + * @example + * HttpResponse.text('hello world') + * HttpResponse.text('Error', { status: 500 }) + */ + static text( + body?: BodyType | null, + init?: HttpResponseInit, + ): StrictResponse { + const responseInit = normalizeResponseInit(init) + responseInit.headers.set('Content-Type', 'text/plain') + return new HttpResponse(body, responseInit) as StrictResponse + } + + /** + * Create a `Response` with a `Content-Type: "application/json"` body. + * @example + * HttpResponse.json({ firstName: 'John' }) + * HttpResponse.json({ error: 'Not Authorized' }, { status: 401 }) + */ + static json( + body?: BodyType | null, + init?: HttpResponseInit, + ): StrictResponse { + const responseInit = normalizeResponseInit(init) + responseInit.headers.set('Content-Type', 'application/json') + return new HttpResponse( + JSON.stringify(body), + responseInit, + ) as StrictResponse + } + + /** + * Create a `Response` with a `Content-Type: "application/xml"` body. + * @example + * HttpResponse.xml(``) + * HttpResponse.xml(`

`, { status: 201 }) + */ + static xml( + body?: BodyType | null, + init?: HttpResponseInit, + ): Response { + const responseInit = normalizeResponseInit(init) + responseInit.headers.set('Content-Type', 'text/xml') + return new HttpResponse(body, responseInit) + } + + /** + * Create a `Response` with an `ArrayBuffer` body. + * @example + * const buffer = new ArrayBuffer(3) + * const view = new Uint8Array(buffer) + * view.set([1, 2, 3]) + * + * HttpResponse.arrayBuffer(buffer) + */ + static arrayBuffer(body?: ArrayBuffer, init?: HttpResponseInit): Response { + const responseInit = normalizeResponseInit(init) + + if (body) { + responseInit.headers.set('Content-Length', body.byteLength.toString()) + } + + return new HttpResponse(body, responseInit) + } + + /** + * Create a `Response` with a `FormData` body. + * @example + * const data = new FormData() + * data.set('name', 'Alice') + * + * HttpResponse.formData(data) + */ + static formData(body?: FormData, init?: HttpResponseInit): Response { + return new HttpResponse(body, normalizeResponseInit(init)) + } +} diff --git a/src/SetupApi.ts b/src/core/SetupApi.ts similarity index 84% rename from src/SetupApi.ts rename to src/core/SetupApi.ts index d345c82fa..d7cae2f48 100644 --- a/src/SetupApi.ts +++ b/src/core/SetupApi.ts @@ -1,7 +1,6 @@ import { invariant } from 'outvariant' import { EventMap, Emitter } from 'strict-event-emitter' import { - DefaultBodyType, RequestHandler, RequestHandlerDefaultInfo, } from './handlers/RequestHandler' @@ -9,12 +8,12 @@ import { LifeCycleEventEmitter } from './sharedOptions' import { devUtils } from './utils/internal/devUtils' import { pipeEvents } from './utils/internal/pipeEvents' import { toReadonlyArray } from './utils/internal/toReadonlyArray' -import { MockedRequest } from './utils/request/MockedRequest' +import { Disposable } from './utils/internal/Disposable' /** * Generic class for the mock API setup. */ -export abstract class SetupApi { +export abstract class SetupApi extends Disposable { protected initialHandlers: ReadonlyArray protected currentHandlers: Array protected readonly emitter: Emitter @@ -23,6 +22,8 @@ export abstract class SetupApi { public readonly events: LifeCycleEventEmitter constructor(...initialHandlers: Array) { + super() + this.validateHandlers(...initialHandlers) this.initialHandlers = toReadonlyArray(initialHandlers) @@ -33,6 +34,11 @@ export abstract class SetupApi { pipeEvents(this.emitter, this.publicEmitter) this.events = this.createLifeCycleEvents() + + this.subscriptions.push(() => { + this.emitter.removeAllListeners() + this.publicEmitter.removeAllListeners() + }) } private validateHandlers(...handlers: ReadonlyArray): void { @@ -48,18 +54,13 @@ export abstract class SetupApi { } } - protected dispose(): void { - this.emitter.removeAllListeners() - this.publicEmitter.removeAllListeners() - } - public use(...runtimeHandlers: Array): void { this.currentHandlers.unshift(...runtimeHandlers) } public restoreHandlers(): void { this.currentHandlers.forEach((handler) => { - handler.markAsSkipped(false) + handler.isUsed = false }) } @@ -69,12 +70,7 @@ export abstract class SetupApi { } public listHandlers(): ReadonlyArray< - RequestHandler< - RequestHandlerDefaultInfo, - MockedRequest, - any, - MockedRequest - > + RequestHandler > { return toReadonlyArray(this.currentHandlers) } @@ -92,6 +88,4 @@ export abstract class SetupApi { }, } } - - abstract printHandlers(): void } diff --git a/src/core/bypass.test.ts b/src/core/bypass.test.ts new file mode 100644 index 000000000..aca432070 --- /dev/null +++ b/src/core/bypass.test.ts @@ -0,0 +1,47 @@ +/** + * @jest-environment jsdom + */ +import { bypass } from './bypass' + +it('returns bypassed request given a request url string', async () => { + const request = bypass('https://api.example.com/resource') + + // Relative URLs are rebased against the current location. + 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 request = bypass(new URL('/resource', 'https://api.example.com')) + + 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 () => { + const original = new Request('http://localhost/resource', { + method: 'POST', + headers: { + 'X-My-Header': 'value', + }, + body: 'hello world', + }) + 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 new file mode 100644 index 000000000..e467cf30c --- /dev/null +++ b/src/core/bypass.ts @@ -0,0 +1,36 @@ +import { invariant } from 'outvariant' + +export type BypassRequestInput = string | URL | Request + +/** + * Creates a `Request` instance that will always be ignored by MSW. + * + * @example + * import { bypass } from 'msw' + * + * 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 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 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. + requestClone.headers.set('x-msw-intention', 'bypass') + + return requestClone +} diff --git a/src/core/delay.ts b/src/core/delay.ts new file mode 100644 index 000000000..0244567ed --- /dev/null +++ b/src/core/delay.ts @@ -0,0 +1,70 @@ +import { isNodeProcess } from 'is-node-process' + +export const SET_TIMEOUT_MAX_ALLOWED_INT = 2147483647 +export const MIN_SERVER_RESPONSE_TIME = 100 +export const MAX_SERVER_RESPONSE_TIME = 400 +export const NODE_SERVER_RESPONSE_TIME = 5 + +function getRealisticResponseTime(): number { + if (isNodeProcess()) { + return NODE_SERVER_RESPONSE_TIME + } + + return Math.floor( + Math.random() * (MAX_SERVER_RESPONSE_TIME - MIN_SERVER_RESPONSE_TIME) + + MIN_SERVER_RESPONSE_TIME, + ) +} + +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, +): Promise { + let delayTime: number + + if (typeof durationOrMode === 'string') { + switch (durationOrMode) { + case 'infinite': { + // Using `Infinity` as a delay value executes the response timeout immediately. + // Instead, use the maximum allowed integer for `setTimeout`. + delayTime = SET_TIMEOUT_MAX_ALLOWED_INT + break + } + case 'real': { + delayTime = getRealisticResponseTime() + break + } + default: { + throw new Error( + `Failed to delay a response: unknown delay mode "${durationOrMode}". Please make sure you provide one of the supported modes ("real", "infinite") or a number.`, + ) + } + } + } else if (typeof durationOrMode === 'undefined') { + // Use random realistic server response time when no explicit delay duration was provided. + delayTime = getRealisticResponseTime() + } else { + // Guard against passing values like `Infinity` or `Number.MAX_VALUE` + // as the response delay duration. They don't produce the result you may expect. + if (durationOrMode > SET_TIMEOUT_MAX_ALLOWED_INT) { + throw new Error( + `Failed to delay a response: provided delay duration (${durationOrMode}) exceeds the maximum allowed duration for "setTimeout" (${SET_TIMEOUT_MAX_ALLOWED_INT}). This will cause the response to be returned immediately. Please use a number within the allowed range to delay the response by exact duration, or consider the "infinite" delay mode to delay the response indefinitely.`, + ) + } + + delayTime = durationOrMode + } + + return new Promise((resolve) => setTimeout(resolve, delayTime)) +} diff --git a/src/graphql.test.ts b/src/core/graphql.test.ts similarity index 100% rename from src/graphql.test.ts rename to src/core/graphql.test.ts index 0658b09a5..e783d0fbd 100644 --- a/src/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 new file mode 100644 index 000000000..b3409eb7f --- /dev/null +++ b/src/core/graphql.ts @@ -0,0 +1,138 @@ +import type { DocumentNode, OperationTypeNode } from 'graphql' +import { + ResponseResolver, + RequestHandlerOptions, +} from './handlers/RequestHandler' +import { + GraphQLHandler, + GraphQLVariables, + ExpectedOperationTypeNode, + GraphQLHandlerNameSelector, + GraphQLResolverExtras, + GraphQLResponseBody, +} from './handlers/GraphQLHandler' +import type { Path } from './utils/matching/matchRequestUrl' + +export interface TypedDocumentNode< + Result = { [key: string]: any }, + Variables = { [key: string]: any }, +> extends DocumentNode { + __apiType?: (variables: Variables) => Result + __resultType?: Result + __variablesType?: Variables +} + +function createScopedGraphQLHandler( + operationType: ExpectedOperationTypeNode, + url: Path, +) { + return < + Query extends Record, + Variables extends GraphQLVariables = GraphQLVariables, + >( + operationName: + | GraphQLHandlerNameSelector + | DocumentNode + | TypedDocumentNode, + resolver: ResponseResolver< + GraphQLResolverExtras, + null, + GraphQLResponseBody + >, + options: RequestHandlerOptions = {}, + ) => { + return new GraphQLHandler( + operationType, + operationName, + url, + resolver, + options, + ) + } +} + +function createGraphQLOperationHandler(url: Path) { + return < + Query extends Record, + Variables extends GraphQLVariables = GraphQLVariables, + >( + resolver: ResponseResolver< + GraphQLResolverExtras, + null, + GraphQLResponseBody + >, + ) => { + return new GraphQLHandler('all', new RegExp('.*'), url, resolver) + } +} + +const standardGraphQLHandlers = { + /** + * 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#graphqlqueryqueryname-resolver `graphql.query()` API reference} + */ + query: createScopedGraphQLHandler('query' as OperationTypeNode, '*'), + + /** + * 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#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 { + return { + operation: createGraphQLOperationHandler(url), + query: createScopedGraphQLHandler('query' as OperationTypeNode, url), + mutation: createScopedGraphQLHandler('mutation' as OperationTypeNode, url), + } +} + +/** + * 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/handlers/GraphQLHandler.test.ts b/src/core/handlers/GraphQLHandler.test.ts similarity index 72% rename from src/handlers/GraphQLHandler.test.ts rename to src/core/handlers/GraphQLHandler.test.ts index 4fefa5d86..cebac6440 100644 --- a/src/handlers/GraphQLHandler.test.ts +++ b/src/core/handlers/GraphQLHandler.test.ts @@ -3,27 +3,25 @@ */ import { encodeBuffer } from '@mswjs/interceptors' import { OperationTypeNode, parse } from 'graphql' -import { Headers } from 'headers-polyfill' -import { context, MockedRequest, MockedRequestInit } from '..' -import { response } from '../response' import { - GraphQLContext, GraphQLHandler, - GraphQLRequest, GraphQLRequestBody, + GraphQLResolverExtras, isDocumentNode, } from './GraphQLHandler' +import { HttpResponse } from '../HttpResponse' import { ResponseResolver } from './RequestHandler' -const resolver: ResponseResolver< - GraphQLRequest<{ userId: string }>, - GraphQLContext -> = (req, res, ctx) => { - return res( - ctx.data({ - user: { id: req.variables.userId }, - }), - ) +const resolver: ResponseResolver> = ({ + variables, +}) => { + return HttpResponse.json({ + data: { + user: { + id: variables.userId, + }, + }, + }) } function createGetGraphQLRequest( @@ -33,17 +31,15 @@ function createGetGraphQLRequest( const requestUrl = new URL(hostname) requestUrl.searchParams.set('query', body?.query) requestUrl.searchParams.set('variables', JSON.stringify(body?.variables)) - return new MockedRequest(requestUrl) + return new Request(requestUrl) } function createPostGraphQLRequest( body: GraphQLRequestBody, hostname = 'https://example.com', - requestInit: MockedRequestInit = {}, ) { - return new MockedRequest(new URL(hostname), { + return new Request(new URL(hostname), { method: 'POST', - ...requestInit, headers: new Headers({ 'Content-Type': 'application/json' }), body: encodeBuffer(JSON.stringify(body)), }) @@ -152,7 +148,7 @@ describe('info', () => { describe('parse', () => { describe('query', () => { - test('parses a query without variables (GET)', () => { + test('parses a query without variables (GET)', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -163,14 +159,15 @@ describe('parse', () => { query: GET_USER, }) - expect(handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'query', operationName: 'GetUser', + query: GET_USER, variables: undefined, }) }) - test('parses a query with variables (GET)', () => { + test('parses a query with variables (GET)', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -184,16 +181,17 @@ describe('parse', () => { }, }) - expect(handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'query', operationName: 'GetUser', + query: GET_USER, variables: { userId: 'abc-123', }, }) }) - test('parses a query without variables (POST)', () => { + test('parses a query without variables (POST)', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -204,14 +202,15 @@ describe('parse', () => { query: GET_USER, }) - expect(handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'query', operationName: 'GetUser', + query: GET_USER, variables: undefined, }) }) - test('parses a query with variables (POST)', () => { + test('parses a query with variables (POST)', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -225,9 +224,10 @@ describe('parse', () => { }, }) - expect(handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'query', operationName: 'GetUser', + query: GET_USER, variables: { userId: 'abc-123', }, @@ -236,7 +236,7 @@ describe('parse', () => { }) describe('mutation', () => { - test('parses a mutation without variables (GET)', () => { + test('parses a mutation without variables (GET)', async () => { const handler = new GraphQLHandler( OperationTypeNode.MUTATION, 'GetUser', @@ -247,14 +247,15 @@ describe('parse', () => { query: LOGIN, }) - expect(handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'mutation', operationName: 'Login', + query: LOGIN, variables: undefined, }) }) - test('parses a mutation with variables (GET)', () => { + test('parses a mutation with variables (GET)', async () => { const handler = new GraphQLHandler( OperationTypeNode.MUTATION, 'GetUser', @@ -268,16 +269,17 @@ describe('parse', () => { }, }) - expect(handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'mutation', operationName: 'Login', + query: LOGIN, variables: { userId: 'abc-123', }, }) }) - test('parses a mutation without variables (POST)', () => { + test('parses a mutation without variables (POST)', async () => { const handler = new GraphQLHandler( OperationTypeNode.MUTATION, 'GetUser', @@ -288,14 +290,15 @@ describe('parse', () => { query: LOGIN, }) - expect(handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'mutation', operationName: 'Login', + query: LOGIN, variables: undefined, }) }) - test('parses a mutation with variables (POST)', () => { + test('parses a mutation with variables (POST)', async () => { const handler = new GraphQLHandler( OperationTypeNode.MUTATION, 'GetUser', @@ -309,9 +312,10 @@ describe('parse', () => { }, }) - expect(handler.parse(request)).toEqual({ + expect(await handler.parse({ request })).toEqual({ operationType: 'mutation', operationName: 'Login', + query: LOGIN, variables: { userId: 'abc-123', }, @@ -321,7 +325,7 @@ describe('parse', () => { }) describe('predicate', () => { - test('respects operation type', () => { + test('respects operation type', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -335,13 +339,21 @@ describe('predicate', () => { query: LOGIN, }) - expect(handler.predicate(request, handler.parse(request))).toBe(true) - expect(handler.predicate(alienRequest, handler.parse(alienRequest))).toBe( - false, - ) + expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) + expect( + handler.predicate({ + request: alienRequest, + parsedResult: await handler.parse({ request: alienRequest }), + }), + ).toBe(false) }) - test('respects operation name', () => { + test('respects operation name', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -361,13 +373,21 @@ describe('predicate', () => { `, }) - expect(handler.predicate(request, handler.parse(request))).toBe(true) - expect(handler.predicate(alienRequest, handler.parse(alienRequest))).toBe( - false, - ) + expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) + expect( + handler.predicate({ + request: alienRequest, + parsedResult: await handler.parse({ request: alienRequest }), + }), + ).toBe(false) }) - test('allows anonymous GraphQL opertaions when using "all" expected operation type', () => { + test('allows anonymous GraphQL opertaions when using "all" expected operation type', async () => { const handler = new GraphQLHandler('all', new RegExp('.*'), '*', resolver) const request = createPostGraphQLRequest({ query: ` @@ -380,10 +400,15 @@ describe('predicate', () => { `, }) - expect(handler.predicate(request, handler.parse(request))).toBe(true) + expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) }) - test('respects custom endpoint', () => { + test('respects custom endpoint', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -400,15 +425,23 @@ describe('predicate', () => { query: GET_USER, }) - expect(handler.predicate(request, handler.parse(request))).toBe(true) - expect(handler.predicate(alienRequest, handler.parse(alienRequest))).toBe( - false, - ) + expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) + expect( + handler.predicate({ + request: alienRequest, + parsedResult: await handler.parse({ request: alienRequest }), + }), + ).toBe(false) }) }) describe('test', () => { - test('respects operation type', () => { + test('respects operation type', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -422,11 +455,11 @@ describe('test', () => { query: LOGIN, }) - expect(handler.test(request)).toBe(true) - expect(handler.test(alienRequest)).toBe(false) + expect(await handler.test({ request })).toBe(true) + expect(await handler.test({ request: alienRequest })).toBe(false) }) - test('respects operation name', () => { + test('respects operation name', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -446,11 +479,11 @@ describe('test', () => { `, }) - expect(handler.test(request)).toBe(true) - expect(handler.test(alienRequest)).toBe(false) + expect(await handler.test({ request })).toBe(true) + expect(await handler.test({ request: alienRequest })).toBe(false) }) - test('respects custom endpoint', () => { + test('respects custom endpoint', async () => { const handler = new GraphQLHandler( OperationTypeNode.QUERY, 'GetUser', @@ -467,8 +500,8 @@ describe('test', () => { query: GET_USER, }) - expect(handler.test(request)).toBe(true) - expect(handler.test(alienRequest)).toBe(false) + expect(await handler.test({ request })).toBe(true) + expect(await handler.test({ request: alienRequest })).toBe(false) }) }) @@ -486,28 +519,27 @@ describe('run', () => { userId: 'abc-123', }, }) - const result = await handler.run(request) + const result = await handler.run({ request }) - expect(result).toMatchObject({ - handler, - request: { - ...request, - variables: { - userId: 'abc-123', - }, - }, - parsedResult: { - operationType: 'query', - operationName: 'GetUser', - variables: { - userId: 'abc-123', - }, + expect(result!.handler).toEqual(handler) + expect(result!.parsedResult).toEqual({ + operationType: 'query', + operationName: 'GetUser', + query: GET_USER, + variables: { + userId: 'abc-123', }, - response: await response( - context.data({ - user: { id: 'abc-123' }, - }), - ), + }) + expect(result!.request.method).toBe('POST') + expect(result!.request.url).toBe('https://example.com/') + expect(await result!.request.json()).toEqual({ + query: GET_USER, + variables: { userId: 'abc-123' }, + }) + expect(result!.response?.status).toBe(200) + expect(result!.response?.statusText).toBe('OK') + expect(await result!.response?.json()).toEqual({ + data: { user: { id: 'abc-123' } }, }) }) @@ -521,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() }) @@ -568,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/handlers/GraphQLHandler.ts b/src/core/handlers/GraphQLHandler.ts similarity index 51% rename from src/handlers/GraphQLHandler.ts rename to src/core/handlers/GraphQLHandler.ts index c44e6dae1..0c3e56e3b 100644 --- a/src/handlers/GraphQLHandler.ts +++ b/src/core/handlers/GraphQLHandler.ts @@ -1,22 +1,15 @@ -import type { DocumentNode, OperationTypeNode } from 'graphql' -import { SerializedResponse } from '../setupWorker/glossary' -import { data } from '../context/data' -import { extensions } from '../context/extensions' -import { errors } from '../context/errors' -import { field } from '../context/field' -import { GraphQLPayloadContext } from '../typeUtils' -import { cookie } from '../context/cookie' +import type { DocumentNode, GraphQLError, OperationTypeNode } from 'graphql' import { - defaultContext, - DefaultContext, + DefaultBodyType, RequestHandler, RequestHandlerDefaultInfo, + RequestHandlerOptions, ResponseResolver, } from './RequestHandler' import { getTimestamp } from '../utils/logging/getTimestamp' import { getStatusCodeColor } from '../utils/logging/getStatusCodeColor' -import { prepareRequest } from '../utils/logging/prepareRequest' -import { prepareResponse } from '../utils/logging/prepareResponse' +import { serializeRequest } from '../utils/logging/serializeRequest' +import { serializeResponse } from '../utils/logging/serializeResponse' import { matchRequestUrl, Path } from '../utils/matching/matchRequestUrl' import { ParsedGraphQLRequest, @@ -25,34 +18,11 @@ import { parseDocumentNode, } from '../utils/internal/parseGraphQLRequest' import { getPublicUrlFromRequest } from '../utils/request/getPublicUrlFromRequest' -import { tryCatch } from '../utils/internal/tryCatch' import { devUtils } from '../utils/internal/devUtils' -import { MockedRequest } from '../utils/request/MockedRequest' export type ExpectedOperationTypeNode = OperationTypeNode | 'all' export type GraphQLHandlerNameSelector = DocumentNode | RegExp | string -// GraphQL related context should contain utility functions -// useful for GraphQL. Functions like `xml()` bear no value -// in the GraphQL universe. -export type GraphQLContext> = - DefaultContext & { - data: GraphQLPayloadContext - extensions: GraphQLPayloadContext - errors: typeof errors - cookie: typeof cookie - field: typeof field - } - -export const graphqlContext: GraphQLContext = { - ...defaultContext, - data, - extensions, - errors, - cookie, - field, -} - export type GraphQLVariables = Record export interface GraphQLHandlerInfo extends RequestHandlerDefaultInfo { @@ -60,6 +30,12 @@ export interface GraphQLHandlerInfo extends RequestHandlerDefaultInfo { operationName: GraphQLHandlerNameSelector } +export type GraphQLResolverExtras = { + query: string + operationName: string + variables: Variables +} + export type GraphQLRequestBody = | GraphQLJsonRequestBody | GraphQLMultipartRequestBody @@ -71,6 +47,11 @@ export interface GraphQLJsonRequestBody { variables?: Variables } +export interface GraphQLResponseBody { + data?: BodyType + errors?: readonly Partial[] +} + export function isDocumentNode( value: DocumentNode | any, ): value is DocumentNode { @@ -81,31 +62,10 @@ export function isDocumentNode( return typeof value === 'object' && 'kind' in value && 'definitions' in value } -export class GraphQLRequest< - Variables extends GraphQLVariables, -> extends MockedRequest> { - constructor( - request: MockedRequest, - public readonly variables: Variables, - public readonly operationName: string, - ) { - super(request.url, { - ...request, - /** - * TODO(https://github.com/mswjs/msw/issues/1318): Cleanup - */ - body: request['_body'], - }) - } -} - -export class GraphQLHandler< - Request extends GraphQLRequest = GraphQLRequest, -> extends RequestHandler< +export class GraphQLHandler extends RequestHandler< GraphQLHandlerInfo, - Request, - ParsedGraphQLRequest | null, - GraphQLRequest + ParsedGraphQLRequest, + GraphQLResolverExtras > { private endpoint: Path @@ -113,7 +73,8 @@ export class GraphQLHandler< operationType: ExpectedOperationTypeNode, operationName: GraphQLHandlerNameSelector, endpoint: Path, - resolver: ResponseResolver, + resolver: ResponseResolver, any, any>, + options?: RequestHandlerOptions, ) { let resolvedOperationName = operationName @@ -146,55 +107,47 @@ export class GraphQLHandler< operationType, operationName: resolvedOperationName, }, - ctx: graphqlContext, resolver, + options, }) this.endpoint = endpoint } - parse(request: MockedRequest) { - return tryCatch( - () => parseGraphQLRequest(request), - (error) => console.error(error.message), - ) - } - - protected getPublicRequest( - request: Request, - parsedResult: ParsedGraphQLRequest, - ): GraphQLRequest { - return new GraphQLRequest( - request, - parsedResult?.variables ?? {}, - parsedResult?.operationName ?? '', - ) + async parse(args: { request: Request }) { + return parseGraphQLRequest(args.request).catch((error) => { + console.error(error) + return undefined + }) } - predicate(request: MockedRequest, 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\ - `) +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(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 && @@ -203,24 +156,35 @@ Consider naming this operation or using "graphql.operation()" request handler to ) } - log( - request: Request, - response: SerializedResponse, - parsedRequest: ParsedGraphQLRequest, - ) { - const loggedRequest = prepareRequest(request) - const loggedResponse = prepareResponse(response) - const statusColor = getStatusCodeColor(response.status) - const requestInfo = parsedRequest?.operationName - ? `${parsedRequest?.operationType} ${parsedRequest?.operationName}` - : `anonymous ${parsedRequest?.operationType}` + protected extendResolverArgs(args: { + request: Request + parsedResult: ParsedGraphQLRequest + }) { + return { + query: args.parsedResult?.query || '', + operationName: args.parsedResult?.operationName || '', + variables: args.parsedResult?.variables || {}, + } + } + + 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 = args.parsedResult?.operationName + ? `${args.parsedResult?.operationType} ${args.parsedResult?.operationName}` + : `anonymous ${args.parsedResult?.operationType}` console.groupCollapsed( devUtils.formatMessage('%s %s (%c%s%c)'), getTimestamp(), `${requestInfo}`, `color:${statusColor}`, - `${response.status} ${response.statusText}`, + `${loggedResponse.status} ${loggedResponse.statusText}`, 'color:inherit', ) console.log('Request:', loggedRequest) diff --git a/src/core/handlers/HttpHandler.test.ts b/src/core/handlers/HttpHandler.test.ts new file mode 100644 index 000000000..0d0ac2d49 --- /dev/null +++ b/src/core/handlers/HttpHandler.test.ts @@ -0,0 +1,218 @@ +/** + * @jest-environment jsdom + */ +import { HttpHandler, HttpRequestResolverExtras } from './HttpHandler' +import { HttpResponse } from '..' +import { ResponseResolver } from './RequestHandler' + +const resolver: ResponseResolver< + HttpRequestResolverExtras<{ userId: string }> +> = ({ params }) => { + return HttpResponse.json({ userId: params.userId }) +} + +describe('info', () => { + test('exposes request handler information', () => { + const handler = new HttpHandler('GET', '/user/:userId', resolver) + + expect(handler.info.header).toEqual('GET /user/:userId') + expect(handler.info.method).toEqual('GET') + expect(handler.info.path).toEqual('/user/:userId') + expect(handler.isUsed).toBe(false) + }) +}) + +describe('parse', () => { + test('parses a URL given a matching request', async () => { + 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({ + match: { + matches: true, + params: { + userId: 'abc-123', + }, + }, + cookies: {}, + }) + }) + + test('parses a URL and ignores the request method', async () => { + const handler = new HttpHandler('GET', '/user/:userId', resolver) + const request = new Request(new URL('/user/def-456', location.href), { + method: 'POST', + }) + + expect(await handler.parse({ request })).toEqual({ + match: { + matches: true, + params: { + userId: 'def-456', + }, + }, + cookies: {}, + }) + }) + + test('returns negative match result given a non-matching request', async () => { + const handler = new HttpHandler('GET', '/user/:userId', resolver) + const request = new Request(new URL('/login', location.href)) + + expect(await handler.parse({ request })).toEqual({ + match: { + matches: false, + params: {}, + }, + cookies: {}, + }) + }) +}) + +describe('predicate', () => { + test('returns true given a matching request', async () => { + const handler = new HttpHandler('POST', '/login', resolver) + const request = new Request(new URL('/login', location.href), { + method: 'POST', + }) + + expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) + }) + + test('respects RegExp as the request method', async () => { + const handler = new HttpHandler(/.+/, '/login', resolver) + const requests = [ + new Request(new URL('/login', location.href)), + new Request(new URL('/login', location.href), { method: 'POST' }), + new Request(new URL('/login', location.href), { method: 'DELETE' }), + ] + + for (const request of requests) { + expect( + handler.predicate({ + request, + parsedResult: await handler.parse({ request }), + }), + ).toBe(true) + } + }) + + test('returns false given a non-matching request', async () => { + const handler = new HttpHandler('POST', '/login', resolver) + const request = new Request(new URL('/user/abc-123', location.href)) + + 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({ + 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) + }) + + test('returns false given a non-matching request', async () => { + const handler = new HttpHandler('GET', '/user/:userId', resolver) + 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) + expect(thirdTest).toBe(false) + }) +}) + +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 }) + + expect(result!.handler).toEqual(handler) + expect(result!.parsedResult).toEqual({ + match: { + matches: true, + params: { + userId: 'abc-123', + }, + }, + cookies: {}, + }) + expect(result!.request.method).toBe('GET') + expect(result!.request.url).toBe('http://localhost/user/abc-123') + expect(result!.response?.status).toBe(200) + expect(result!.response?.statusText).toBe('OK') + expect(await result?.response?.json()).toEqual({ userId: 'abc-123' }) + }) + + test('returns null given a non-matching request', async () => { + const handler = new HttpHandler('POST', '/login', resolver) + 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({ + request: new Request(new URL('/users', location.href)), + }) + + expect(result?.parsedResult?.match?.params).toEqual({}) + }) + + test('exhauses resolver until its generator completes', async () => { + const handler = new HttpHandler('GET', '/users', function* () { + let count = 0 + + while (count < 5) { + count += 1 + yield HttpResponse.text('pending') + } + + return HttpResponse.text('complete') + }) + + const run = async () => { + const result = await handler.run({ + request: new Request(new URL('/users', location.href)), + }) + return result?.response?.text() + } + + expect(await run()).toBe('pending') + expect(await run()).toBe('pending') + expect(await run()).toBe('pending') + expect(await run()).toBe('pending') + expect(await run()).toBe('pending') + expect(await run()).toBe('complete') + expect(await run()).toBe('complete') + }) +}) diff --git a/src/core/handlers/HttpHandler.ts b/src/core/handlers/HttpHandler.ts new file mode 100644 index 000000000..963814785 --- /dev/null +++ b/src/core/handlers/HttpHandler.ts @@ -0,0 +1,169 @@ +import { ResponseResolutionContext } from '../utils/getResponse' +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 { serializeRequest } from '../utils/logging/serializeRequest' +import { serializeResponse } from '../utils/logging/serializeResponse' +import { + matchRequestUrl, + Match, + Path, + PathParams, +} from '../utils/matching/matchRequestUrl' +import { getPublicUrlFromRequest } from '../utils/request/getPublicUrlFromRequest' +import { getAllRequestCookies } from '../utils/request/getRequestCookies' +import { cleanUrl, getSearchParams } from '../utils/url/cleanUrl' +import { + RequestHandler, + RequestHandlerDefaultInfo, + RequestHandlerOptions, + ResponseResolver, +} from './RequestHandler' + +type HttpHandlerMethod = string | RegExp + +export interface HttpHandlerInfo extends RequestHandlerDefaultInfo { + method: HttpHandlerMethod + path: Path +} + +export enum HttpMethods { + HEAD = 'HEAD', + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + PATCH = 'PATCH', + OPTIONS = 'OPTIONS', + DELETE = 'DELETE', +} + +export type RequestQuery = { + [queryName: string]: string +} + +export type HttpRequestParsedResult = { + match: Match + cookies: Record +} + +export type HttpRequestResolverExtras = { + params: Params + cookies: Record> +} + +/** + * Request handler for HTTP requests. + * Provides request matching based on method and URL. + */ +export class HttpHandler extends RequestHandler< + HttpHandlerInfo, + HttpRequestParsedResult, + HttpRequestResolverExtras +> { + constructor( + method: HttpHandlerMethod, + path: Path, + resolver: ResponseResolver, any, any>, + options?: RequestHandlerOptions, + ) { + super({ + info: { + header: `${method} ${path}`, + path, + method, + }, + resolver, + options, + }) + + this.checkRedundantQueryParameters() + } + + private checkRedundantQueryParameters() { + const { method, path } = this.info + + if (path instanceof RegExp) { + return + } + + const url = cleanUrl(path) + + // Bypass request handler URLs that have no redundant characters. + if (url === path) { + return + } + + const searchParams = getSearchParams(path) + const queryParams: string[] = [] + + searchParams.forEach((_, paramName) => { + queryParams.push(paramName) + }) + + devUtils.warn( + `Found a redundant usage of query parameters in the request handler URL for "${method} ${path}". Please match against a path instead and access query parameters in the response resolver function using "req.url.searchParams".`, + ) + } + + async parse(args: { + request: Request + resolutionContext?: ResponseResolutionContext + }) { + const url = new URL(args.request.url) + const match = matchRequestUrl( + url, + this.info.path, + args.resolutionContext?.baseUrl, + ) + const cookies = getAllRequestCookies(args.request) + + return { + match, + cookies, + } + } + + predicate(args: { request: Request; parsedResult: HttpRequestParsedResult }) { + const hasMatchingMethod = this.matchMethod(args.request.method) + const hasMatchingUrl = args.parsedResult.match.matches + return hasMatchingMethod && hasMatchingUrl + } + + private matchMethod(actualMethod: string): boolean { + return this.info.method instanceof RegExp + ? this.info.method.test(actualMethod) + : isStringEqual(this.info.method, actualMethod) + } + + protected extendResolverArgs(args: { + request: Request + parsedResult: HttpRequestParsedResult + }) { + return { + params: args.parsedResult.match?.params || {}, + cookies: args.parsedResult.cookies, + } + } + + 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(), + args.request.method, + publicUrl, + `color:${statusColor}`, + `${loggedResponse.status} ${loggedResponse.statusText}`, + 'color:inherit', + ) + console.log('Request', loggedRequest) + console.log('Handler:', this) + console.log('Response', loggedResponse) + console.groupEnd() + } +} diff --git a/src/core/handlers/RequestHandler.ts b/src/core/handlers/RequestHandler.ts new file mode 100644 index 000000000..917d6edc4 --- /dev/null +++ b/src/core/handlers/RequestHandler.ts @@ -0,0 +1,297 @@ +import { invariant } from 'outvariant' +import { getCallFrame } from '../utils/internal/getCallFrame' +import { isIterable } from '../utils/internal/isIterable' +import type { ResponseResolutionContext } from '../utils/getResponse' +import type { MaybePromise } from '../typeUtils' +import { StrictRequest, StrictResponse } from '..//HttpResponse' + +export type DefaultRequestMultipartBody = Record< + string, + string | File | Array +> + +export type DefaultBodyType = + | Record + | DefaultRequestMultipartBody + | string + | number + | boolean + | null + | undefined + +export interface RequestHandlerDefaultInfo { + header: string +} + +export interface RequestHandlerInternalInfo { + callFrame?: string +} + +export type ResponseResolverReturnType< + BodyType extends DefaultBodyType = undefined, +> = + | (BodyType extends undefined ? Response : StrictResponse) + | undefined + | void + +export type MaybeAsyncResponseResolverReturnType< + BodyType extends DefaultBodyType, +> = MaybePromise> + +export type AsyncResponseResolverReturnType = + | MaybeAsyncResponseResolverReturnType + | Generator< + MaybeAsyncResponseResolverReturnType, + MaybeAsyncResponseResolverReturnType, + MaybeAsyncResponseResolverReturnType + > + +export type ResponseResolverInfo< + ResolverExtraInfo extends Record, + RequestBodyType extends DefaultBodyType = DefaultBodyType, +> = { + request: StrictRequest +} & ResolverExtraInfo + +export type ResponseResolver< + ResolverExtraInfo extends Record = Record, + RequestBodyType extends DefaultBodyType = DefaultBodyType, + ResponseBodyType extends DefaultBodyType = undefined, +> = ( + info: ResponseResolverInfo, +) => AsyncResponseResolverReturnType + +export interface RequestHandlerArgs< + HandlerInfo, + HandlerOptions extends RequestHandlerOptions, +> { + info: HandlerInfo + resolver: ResponseResolver + options?: HandlerOptions +} + +export interface RequestHandlerOptions { + once?: boolean +} + +export interface RequestHandlerExecutionResult< + ParsedResult extends Record | undefined, +> { + handler: RequestHandler + parsedResult?: ParsedResult + request: Request + response?: Response +} + +export abstract class RequestHandler< + HandlerInfo extends RequestHandlerDefaultInfo = RequestHandlerDefaultInfo, + ParsedResult extends Record | undefined = any, + ResolverExtras extends Record = any, + HandlerOptions extends RequestHandlerOptions = RequestHandlerOptions, +> { + public info: HandlerInfo & RequestHandlerInternalInfo + /** + * Indicates whether this request handler has been used + * (its resolver has successfully executed). + */ + public isUsed: boolean + + protected resolver: ResponseResolver + private resolverGenerator?: Generator< + MaybeAsyncResponseResolverReturnType, + MaybeAsyncResponseResolverReturnType, + MaybeAsyncResponseResolverReturnType + > + private resolverGeneratorResult?: Response | StrictResponse + private options?: HandlerOptions + + constructor(args: RequestHandlerArgs) { + this.resolver = args.resolver + this.options = args.options + + const callFrame = getCallFrame(new Error()) + + this.info = { + ...args.info, + callFrame, + } + + this.isUsed = false + } + + /** + * Determine if the intercepted request should be mocked. + */ + abstract predicate(args: { + request: Request + parsedResult: ParsedResult + resolutionContext?: ResponseResolutionContext + }): boolean + + /** + * Print out the successfully handled request. + */ + abstract log(args: { + request: Request + response: Response + parsedResult: ParsedResult + }): void + + /** + * Parse the intercepted request to extract additional information from it. + * Parsed result is then exposed to other methods of this request handler. + */ + async parse(_args: { + request: Request + resolutionContext?: ResponseResolutionContext + }): Promise { + return {} as ParsedResult + } + + /** + * Test if this handler matches the given request. + */ + 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 extendResolverArgs(_args: { + request: Request + parsedResult: ParsedResult + }): ResolverExtras { + return {} as ResolverExtras + } + + /** + * Execute this request handler and produce a mocked response + * using the given resolver function. + */ + public async run(args: { + request: StrictRequest + resolutionContext?: ResponseResolutionContext + }): Promise | null> { + if (this.isUsed && this.options?.once) { + return null + } + + // 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({ + request: args.request, + resolutionContext: args.resolutionContext, + }) + const shouldInterceptRequest = this.predicate({ + request: args.request, + parsedResult, + resolutionContext: args.resolutionContext, + }) + + if (!shouldInterceptRequest) { + return null + } + + // Create a response extraction wrapper around the resolver + // since it can be both an async function and a generator. + const executeResolver = this.wrapResolver(this.resolver) + + const resolverExtras = this.extendResolverArgs({ + request: args.request, + parsedResult, + }) + const mockedResponse = (await executeResolver({ + ...resolverExtras, + request: args.request, + })) as Response + + const executionResult = this.createExecutionResult({ + // Pass the cloned request to the result so that logging + // and other consumers could read its body once more. + request: mainRequestRef, + response: mockedResponse, + parsedResult, + }) + + return executionResult + } + + private wrapResolver( + resolver: ResponseResolver, + ): ResponseResolver { + return async (info): Promise> => { + const result = this.resolverGenerator || (await resolver(info)) + + if (isIterable>(result)) { + // Immediately mark this handler as unused. + // Only when the generator is done, the handler will be + // considered used. + this.isUsed = false + + const { value, done } = result[Symbol.iterator]().next() + const nextResponse = await value + + if (done) { + this.isUsed = true + } + + // If the generator is done and there is no next value, + // return the previous generator's value. + if (!nextResponse && done) { + invariant( + this.resolverGeneratorResult, + 'Failed to returned a previously stored generator response: the value is not a valid Response.', + ) + + // Clone the previously stored response from the generator + // so that it could be read again. + return this.resolverGeneratorResult.clone() + } + + if (!this.resolverGenerator) { + this.resolverGenerator = result + } + + if (nextResponse) { + // Also clone the response before storing it + // so it could be read again. + this.resolverGeneratorResult = nextResponse?.clone() + } + + return nextResponse + } + + return result + } + } + + private createExecutionResult(args: { + request: Request + parsedResult: ParsedResult + response?: Response + }): RequestHandlerExecutionResult { + return { + handler: this, + request: args.request, + response: args.response, + parsedResult: args.parsedResult, + } + } +} diff --git a/src/rest.spec.ts b/src/core/http.spec.ts similarity index 61% rename from src/rest.spec.ts rename to src/core/http.spec.ts index c44f0a85e..8bc2bdc2d 100644 --- a/src/rest.spec.ts +++ b/src/core/http.spec.ts @@ -1,8 +1,8 @@ -import { rest } from './rest' +import { http } from './http' test('exports all REST API methods', () => { - expect(rest).toBeDefined() - expect(Object.keys(rest)).toEqual([ + expect(http).toBeDefined() + expect(Object.keys(http)).toEqual([ 'all', 'head', 'get', diff --git a/src/core/http.ts b/src/core/http.ts new file mode 100644 index 000000000..d4f7c58f8 --- /dev/null +++ b/src/core/http.ts @@ -0,0 +1,51 @@ +import { + DefaultBodyType, + RequestHandlerOptions, + ResponseResolver, +} from './handlers/RequestHandler' +import { + HttpMethods, + HttpHandler, + HttpRequestResolverExtras, +} from './handlers/HttpHandler' +import type { Path, PathParams } from './utils/matching/matchRequestUrl' + +function createHttpHandler( + method: Method, +) { + return < + Params extends PathParams = PathParams, + RequestBodyType extends DefaultBodyType = DefaultBodyType, + ResponseBodyType extends DefaultBodyType = undefined, + >( + path: Path, + resolver: ResponseResolver< + HttpRequestResolverExtras, + RequestBodyType, + ResponseBodyType + >, + options: RequestHandlerOptions = {}, + ) => { + return new HttpHandler(method, path, resolver, options) + } +} + +/** + * 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), + get: createHttpHandler(HttpMethods.GET), + post: createHttpHandler(HttpMethods.POST), + put: createHttpHandler(HttpMethods.PUT), + delete: createHttpHandler(HttpMethods.DELETE), + patch: createHttpHandler(HttpMethods.PATCH), + options: createHttpHandler(HttpMethods.OPTIONS), +} diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 000000000..d7bf7f968 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,55 @@ +import { checkGlobals } from './utils/internal/checkGlobals' + +export { SetupApi } from './SetupApi' + +/* Request handlers */ +export { RequestHandler } from './handlers/RequestHandler' +export { http } from './http' +export { HttpHandler, HttpMethods } from './handlers/HttpHandler' +export { graphql } from './graphql' +export { GraphQLHandler } from './handlers/GraphQLHandler' + +/* Utils */ +export { matchRequestUrl } from './utils/matching/matchRequestUrl' +export * from './utils/handleRequest' +export { cleanUrl } from './utils/url/cleanUrl' + +/** + * Type definitions. + */ + +export type { SharedOptions, LifeCycleEventsMap } from './sharedOptions' + +export type { + ResponseResolver, + ResponseResolverReturnType, + AsyncResponseResolverReturnType, + RequestHandlerOptions, + DefaultBodyType, + DefaultRequestMultipartBody, +} from './handlers/RequestHandler' + +export type { + RequestQuery, + HttpRequestParsedResult, +} from './handlers/HttpHandler' + +export type { + GraphQLVariables, + GraphQLRequestBody, + GraphQLJsonRequestBody, +} from './handlers/GraphQLHandler' + +export type { Path, PathParams, Match } from './utils/matching/matchRequestUrl' +export type { ParsedGraphQLRequest } from './utils/internal/parseGraphQLRequest' + +export * from './HttpResponse' +export * from './delay' +export { bypass } from './bypass' +export { passthrough } from './passthrough' + +// Validate environmental globals before executing any code. +// This ensures that the library gives user-friendly errors +// when ran in the environments that require additional polyfills +// from the end user. +checkGlobals() diff --git a/src/core/passthrough.test.ts b/src/core/passthrough.test.ts new file mode 100644 index 000000000..631fcdaf3 --- /dev/null +++ b/src/core/passthrough.test.ts @@ -0,0 +1,13 @@ +/** + * @jest-environment node + */ +import { passthrough } from './passthrough' + +it('creates a 302 response with the intention header', () => { + const response = passthrough() + + expect(response).toBeInstanceOf(Response) + expect(response.status).toBe(302) + expect(response.statusText).toBe('Passthrough') + expect(response.headers.get('x-msw-intention')).toBe('passthrough') +}) diff --git a/src/core/passthrough.ts b/src/core/passthrough.ts new file mode 100644 index 000000000..d67afdde1 --- /dev/null +++ b/src/core/passthrough.ts @@ -0,0 +1,23 @@ +/** + * Performs the intercepted request as-is. + * + * This stops request handler lookup so no other handlers + * can affect this request past this point. + * Unlike `bypass()`, this will not trigger an additional request. + * + * @example + * http.get('/resource', () => { + * return passthrough() + * }) + * + * @see {@link https://mswjs.io/docs/api/passthrough `passthrough()` API reference} + */ +export function passthrough(): Response { + return new Response(null, { + status: 302, + statusText: 'Passthrough', + headers: { + 'x-msw-intention': 'passthrough', + }, + }) +} diff --git a/src/core/sharedOptions.ts b/src/core/sharedOptions.ts new file mode 100644 index 000000000..ad7f151a2 --- /dev/null +++ b/src/core/sharedOptions.ts @@ -0,0 +1,66 @@ +import type { Emitter } from 'strict-event-emitter' +import type { UnhandledRequestStrategy } from './utils/request/onUnhandledRequest' + +export interface SharedOptions { + /** + * Specifies how to react to a request that has no corresponding + * request handler. Warns on unhandled requests by default. + * + * @example worker.start({ onUnhandledRequest: 'bypass' }) + * @example worker.start({ onUnhandledRequest: 'warn' }) + * @example server.listen({ onUnhandledRequest: 'error' }) + */ + onUnhandledRequest?: UnhandledRequestStrategy +} + +export type LifeCycleEventsMap = { + 'request:start': [ + args: { + request: Request + requestId: string + }, + ] + 'request:match': [ + args: { + request: Request + requestId: string + }, + ] + 'request:unhandled': [ + args: { + request: Request + requestId: string + }, + ] + 'request:end': [ + args: { + request: Request + requestId: string + }, + ] + 'response:mocked': [ + args: { + response: Response + request: Request + requestId: string + }, + ] + 'response:bypass': [ + args: { + response: Response + request: Request + requestId: string + }, + ] + unhandledException: [ + args: { + error: Error + request: Request + requestId: string + }, + ] +} + +export type LifeCycleEventEmitter< + EventsMap extends Record, +> = Pick, 'on' | 'removeListener' | 'removeAllListeners'> diff --git a/src/typeUtils.ts b/src/core/typeUtils.ts similarity index 73% rename from src/typeUtils.ts rename to src/core/typeUtils.ts index d9310465d..8e878be22 100644 --- a/src/typeUtils.ts +++ b/src/core/typeUtils.ts @@ -1,7 +1,7 @@ -import { ResponseTransformer } from './response' - type Fn = (...arg: any[]) => any +export type MaybePromise = T | Promise + export type RequiredDeep< Type, U extends Record | Fn | undefined = undefined, @@ -18,7 +18,3 @@ export type RequiredDeep< : RequiredDeep, U> } : Type - -export type GraphQLPayloadContext> = ( - payload: QueryType, -) => ResponseTransformer diff --git a/src/core/utils/HttpResponse/decorators.ts b/src/core/utils/HttpResponse/decorators.ts new file mode 100644 index 000000000..7b1bb97cc --- /dev/null +++ b/src/core/utils/HttpResponse/decorators.ts @@ -0,0 +1,56 @@ +import statuses from '@bundled-es-modules/statuses' +import type { HttpResponseInit } from '../../HttpResponse' + +const { message } = statuses + +export interface HttpResponseDecoratedInit extends HttpResponseInit { + status: number + statusText: string + headers: Headers +} + +export function normalizeResponseInit( + init: HttpResponseInit = {}, +): HttpResponseDecoratedInit { + const status = init?.status || 200 + const statusText = init?.statusText || message[status] || '' + const headers = new Headers(init?.headers) + + return { + ...init, + headers, + status, + statusText, + } +} + +export function decorateResponse( + response: Response, + init: HttpResponseDecoratedInit, +): Response { + // Allow to mock the response type. + if (init.type) { + Object.defineProperty(response, 'type', { + value: init.type, + enumerable: true, + writable: false, + }) + } + + // Cookie forwarding is only relevant in the browser. + if (typeof document !== 'undefined') { + // Write the mocked response cookies to the document. + // Note that Fetch API Headers will concatenate multiple "Set-Cookie" + // headers into a single comma-separated string, just as it does + // with any other multi-value headers. + const responseCookies = init.headers.get('Set-Cookie')?.split(',') || [] + + for (const cookieString of responseCookies) { + // No need to parse the cookie headers because it's defined + // as the valid cookie string to begin with. + document.cookie = cookieString + } + } + + return response +} diff --git a/src/core/utils/getResponse.ts b/src/core/utils/getResponse.ts new file mode 100644 index 000000000..131804d8b --- /dev/null +++ b/src/core/utils/getResponse.ts @@ -0,0 +1,55 @@ +import { + RequestHandler, + RequestHandlerExecutionResult, +} from '../handlers/RequestHandler' + +export interface ResponseLookupResult { + handler: RequestHandler + parsedResult?: any + response?: Response +} + +export interface ResponseResolutionContext { + baseUrl?: string +} + +/** + * Returns a mocked response for a given request using following request handlers. + */ +export const getResponse = async >( + request: Request, + handlers: Handler, + resolutionContext?: ResponseResolutionContext, +): Promise => { + let matchingHandler: RequestHandler | null = null + let result: RequestHandlerExecutionResult | null = null + + for (const handler of handlers) { + result = await handler.run({ request, resolutionContext }) + + // If the handler produces some result for this request, + // it automatically becomes matching. + if (result !== null) { + matchingHandler = handler + } + + // Stop the lookup if this handler returns a mocked response. + // If it doesn't, it will still be considered the last matching + // handler until any of them returns a response. This way we can + // distinguish between fallthrough handlers without responses + // and the lack of a matching handler. + if (result?.response) { + break + } + } + + if (matchingHandler) { + return { + handler: matchingHandler, + parsedResult: result?.parsedResult, + response: result?.response, + } + } + + return null +} diff --git a/src/core/utils/handleRequest.test.ts b/src/core/utils/handleRequest.test.ts new file mode 100644 index 000000000..a89bd00f3 --- /dev/null +++ b/src/core/utils/handleRequest.test.ts @@ -0,0 +1,344 @@ +/** + * @jest-environment jsdom + */ +import { Emitter } from 'strict-event-emitter' +import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions' +import { RequestHandler } from '../handlers/RequestHandler' +import { http } from '../http' +import { handleRequest, HandleRequestOptions } from './handleRequest' +import { RequiredDeep } from '../typeUtils' +import { uuidv4 } from './internal/uuidv4' +import { HttpResponse } from '../HttpResponse' +import { passthrough } from '../passthrough' + +const options: RequiredDeep = { + onUnhandledRequest: jest.fn(), +} +const callbacks: Partial> = { + onPassthroughResponse: jest.fn(), + onMockedResponse: jest.fn(), +} + +function setup() { + const emitter = new Emitter() + const listener = jest.fn() + + const createMockListener = (name: string) => { + return (...args: any) => { + listener(name, ...args) + } + } + + emitter.on('request:start', createMockListener('request:start')) + emitter.on('request:match', createMockListener('request:match')) + emitter.on('request:unhandled', createMockListener('request:unhandled')) + emitter.on('request:end', createMockListener('request:end')) + emitter.on('response:mocked', createMockListener('response:mocked')) + emitter.on('response:bypass', createMockListener('response:bypass')) + + const events = listener.mock.calls + return { emitter, events } +} + +beforeEach(() => { + jest.spyOn(global.console, 'warn').mockImplementation() +}) + +afterEach(() => { + jest.resetAllMocks() +}) + +test('returns undefined for a request with the "x-msw-intention" header equal to "bypass"', async () => { + const { emitter, events } = setup() + + const requestId = uuidv4() + const request = new Request(new URL('http://localhost/user'), { + headers: new Headers({ + 'x-msw-intention': 'bypass', + }), + }) + const handlers: Array = [] + + const result = await handleRequest( + request, + requestId, + handlers, + options, + emitter, + callbacks, + ) + + expect(result).toBeUndefined() + expect(events).toEqual([ + ['request:start', { request, requestId }], + ['request:end', { request, requestId }], + ]) + expect(options.onUnhandledRequest).not.toHaveBeenCalled() + expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) + expect(callbacks.onMockedResponse).not.toHaveBeenCalled() +}) + +test('does not bypass a request with "x-msw-intention" header set to arbitrary value', async () => { + const { emitter } = setup() + + const request = new Request(new URL('http://localhost/user'), { + headers: new Headers({ + 'x-msw-intention': 'invalid', + }), + }) + const handlers: Array = [ + http.get('/user', () => { + return HttpResponse.text('hello world') + }), + ] + + const result = await handleRequest( + request, + uuidv4(), + handlers, + options, + emitter, + callbacks, + ) + + expect(result).not.toBeUndefined() + expect(options.onUnhandledRequest).not.toHaveBeenCalled() + expect(callbacks.onMockedResponse).toHaveBeenCalledTimes(1) +}) + +test('reports request as unhandled when it has no matching request handlers', async () => { + const { emitter, events } = setup() + + const requestId = uuidv4() + const request = new Request(new URL('http://localhost/user')) + const handlers: Array = [] + + const result = await handleRequest( + request, + requestId, + handlers, + options, + emitter, + callbacks, + ) + + expect(result).toBeUndefined() + expect(events).toEqual([ + ['request:start', { request, requestId }], + ['request:unhandled', { request, requestId }], + ['request:end', { request, requestId }], + ]) + expect(options.onUnhandledRequest).toHaveBeenNthCalledWith(1, request, { + warning: expect.any(Function), + error: expect.any(Function), + }) + expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) + expect(callbacks.onMockedResponse).not.toHaveBeenCalled() +}) + +test('returns undefined on a request handler that returns no response', async () => { + const { emitter, events } = setup() + + const requestId = uuidv4() + const request = new Request(new URL('http://localhost/user')) + const handlers: Array = [ + http.get('/user', () => { + // Intentionally blank response resolver. + return + }), + ] + + const result = await handleRequest( + request, + requestId, + handlers, + options, + emitter, + callbacks, + ) + + expect(result).toBeUndefined() + expect(events).toEqual([ + ['request:start', { request, requestId }], + ['request:end', { request, requestId }], + ]) + expect(options.onUnhandledRequest).not.toHaveBeenCalled() + expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) + expect(callbacks.onMockedResponse).not.toHaveBeenCalled() + + /** + * @note Returning undefined from a resolver no longer prints a warning. + */ + expect(console.warn).toHaveBeenCalledTimes(0) +}) + +test('returns the mocked response for a request with a matching request handler', async () => { + const { emitter, events } = setup() + + const requestId = uuidv4() + const request = new Request(new URL('http://localhost/user')) + const mockedResponse = HttpResponse.json({ firstName: 'John' }) + const handlers: Array = [ + http.get('/user', () => { + return mockedResponse + }), + ] + const lookupResult = { + handler: handlers[0], + response: mockedResponse, + request, + parsedResult: { + match: { matches: true, params: {} }, + cookies: {}, + }, + } + + const result = await handleRequest( + request, + requestId, + handlers, + options, + emitter, + callbacks, + ) + + expect(result).toEqual(mockedResponse) + expect(events).toEqual([ + ['request:start', { request, requestId }], + ['request:match', { request, requestId }], + ['request:end', { request, requestId }], + ]) + expect(callbacks.onPassthroughResponse).not.toHaveBeenCalled() + + expect(callbacks.onMockedResponse).toHaveBeenCalledTimes(1) + const [mockedResponseParam, lookupResultParam] = + callbacks.onMockedResponse.mock.calls[0] + + expect(mockedResponseParam.status).toBe(mockedResponse.status) + expect(mockedResponseParam.statusText).toBe(mockedResponse.statusText) + expect(Object.fromEntries(mockedResponseParam.headers.entries())).toEqual( + Object.fromEntries(mockedResponse.headers.entries()), + ) + + expect(lookupResultParam).toEqual({ + handler: lookupResult.handler, + parsedResult: lookupResult.parsedResult, + response: expect.objectContaining({ + status: lookupResult.response.status, + statusText: lookupResult.response.statusText, + }), + }) +}) + +test('returns a transformed response if the "transformResponse" option is provided', async () => { + const { emitter, events } = setup() + + const requestId = uuidv4() + const request = new Request(new URL('http://localhost/user')) + const mockedResponse = HttpResponse.json({ firstName: 'John' }) + const handlers: Array = [ + http.get('/user', () => { + return mockedResponse + }), + ] + const transformResponseImpelemntation = (response: Response): Response => { + return new Response('transformed', response) + } + const transformResponse = jest + .fn() + .mockImplementation(transformResponseImpelemntation) + const finalResponse = transformResponseImpelemntation(mockedResponse) + const lookupResult = { + handler: handlers[0], + response: mockedResponse, + request, + parsedResult: { + match: { matches: true, params: {} }, + cookies: {}, + }, + } + + const result = await handleRequest( + request, + requestId, + handlers, + options, + emitter, + { + ...callbacks, + transformResponse, + }, + ) + + expect(result?.status).toEqual(finalResponse.status) + expect(result?.statusText).toEqual(finalResponse.statusText) + expect(Object.fromEntries(result!.headers.entries())).toEqual( + Object.fromEntries(mockedResponse.headers.entries()), + ) + + expect(events).toEqual([ + ['request:start', { request, requestId }], + ['request:match', { request, requestId }], + ['request:end', { request, requestId }], + ]) + expect(callbacks.onPassthroughResponse).not.toHaveBeenCalled() + + expect(transformResponse).toHaveBeenCalledTimes(1) + const [responseParam] = transformResponse.mock.calls[0] + + expect(responseParam.status).toBe(mockedResponse.status) + expect(responseParam.statusText).toBe(mockedResponse.statusText) + expect(Object.fromEntries(responseParam.headers.entries())).toEqual( + Object.fromEntries(mockedResponse.headers.entries()), + ) + + expect(callbacks.onMockedResponse).toHaveBeenCalledTimes(1) + const [mockedResponseParam, lookupResultParam] = + callbacks.onMockedResponse.mock.calls[0] + + expect(mockedResponseParam.status).toBe(finalResponse.status) + expect(mockedResponseParam.statusText).toBe(finalResponse.statusText) + expect(Object.fromEntries(mockedResponseParam.headers.entries())).toEqual( + Object.fromEntries(mockedResponse.headers.entries()), + ) + expect(await mockedResponseParam.text()).toBe('transformed') + + expect(lookupResultParam).toEqual({ + handler: lookupResult.handler, + parsedResult: lookupResult.parsedResult, + response: expect.objectContaining({ + status: lookupResult.response.status, + statusText: lookupResult.response.statusText, + }), + }) +}) + +it('returns undefined without warning on a passthrough request', async () => { + const { emitter, events } = setup() + + const requestId = uuidv4() + const request = new Request(new URL('http://localhost/user')) + const handlers: Array = [ + http.get('/user', () => { + return passthrough() + }), + ] + + const result = await handleRequest( + request, + requestId, + handlers, + options, + emitter, + callbacks, + ) + + expect(result).toBeUndefined() + expect(events).toEqual([ + ['request:start', { request, requestId }], + ['request:end', { request, requestId }], + ]) + expect(options.onUnhandledRequest).not.toHaveBeenCalled() + expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) + expect(callbacks.onMockedResponse).not.toHaveBeenCalled() +}) diff --git a/src/utils/handleRequest.ts b/src/core/utils/handleRequest.ts similarity index 50% rename from src/utils/handleRequest.ts rename to src/core/utils/handleRequest.ts index 71e4f2ae1..f70002179 100644 --- a/src/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -1,17 +1,13 @@ import { until } from '@open-draft/until' import { Emitter } from 'strict-event-emitter' import { RequestHandler } from '../handlers/RequestHandler' -import { ServerLifecycleEventsMap } from '../node/glossary' -import { MockedResponse } from '../response' -import { SharedOptions } from '../sharedOptions' +import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions' import { RequiredDeep } from '../typeUtils' import { ResponseLookupResult, getResponse } from './getResponse' -import { devUtils } from './internal/devUtils' -import { MockedRequest } from './request/MockedRequest' import { onUnhandledRequest } from './request/onUnhandledRequest' import { readResponseCookies } from './request/readResponseCookies' -export interface HandleRequestOptions { +export interface HandleRequestOptions { /** * Options for the response resolution process. */ @@ -23,42 +19,41 @@ export interface HandleRequestOptions { * Transforms a `MockedResponse` instance returned from a handler * to a response instance supported by the lower tooling (i.e. interceptors). */ - transformResponse?(response: MockedResponse): ResponseType + transformResponse?(response: Response): Response /** * Invoked whenever a request is performed as-is. */ - onPassthroughResponse?(request: MockedRequest): void + onPassthroughResponse?(request: Request): void /** * Invoked when the mocked response is ready to be sent. */ onMockedResponse?( - response: ResponseType, + response: Response, handler: RequiredDeep, ): void } -export async function handleRequest< - ResponseType extends Record = MockedResponse, ->( - request: MockedRequest, - handlers: RequestHandler[], +export async function handleRequest( + request: Request, + requestId: string, + handlers: Array, options: RequiredDeep, - emitter: Emitter, - handleRequestOptions?: HandleRequestOptions, -): Promise { - emitter.emit('request:start', request) + emitter: Emitter, + handleRequestOptions?: HandleRequestOptions, +): Promise { + emitter.emit('request:start', { request, requestId }) // Perform bypassed requests (i.e. issued via "ctx.fetch") as-is. - if (request.headers.get('x-msw-bypass') === 'true') { - emitter.emit('request:end', request) + if (request.headers.get('x-msw-intention') === 'bypass') { + emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return } // Resolve a mocked response from the list of request handlers. - const [lookupError, lookupResult] = await until(() => { + const lookupResult = await until(() => { return getResponse( request, handlers, @@ -66,48 +61,43 @@ export async function handleRequest< ) }) - if (lookupError) { + if (lookupResult.error) { // Allow developers to react to unhandled exceptions in request handlers. - emitter.emit('unhandledException', lookupError, request) - throw lookupError + emitter.emit('unhandledException', { + error: lookupResult.error, + request, + requestId, + }) + throw lookupResult.error } - const { handler, response } = lookupResult - - // When there's no handler for the request, consider it unhandled. - // Allow the developer to react to such cases. - if (!handler) { - onUnhandledRequest(request, handlers, options.onUnhandledRequest) - emitter.emit('request:unhandled', request) - emitter.emit('request:end', request) + // If the handler lookup returned nothing, no request handler was found + // matching this request. Report the request as unhandled. + if (!lookupResult.data) { + await onUnhandledRequest(request, handlers, options.onUnhandledRequest) + emitter.emit('request:unhandled', { request, requestId }) + emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return } + 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) { - devUtils.warn( - `\ -Expected response resolver to return a mocked response Object, but got %s. The original response is going to be used instead.\ -\n - \u2022 %s - %s\ -`, - response, - handler.info.header, - handler.info.callFrame, - ) - - emitter.emit('request:end', request) + emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return } - // When the developer explicitly returned "req.passthrough()" do not warn them. - // Perform the request as-is. - if (response.passthrough) { - emitter.emit('request:end', request) + // Perform the request as-is when the developer explicitly returned "req.passthrough()". + // This produces no warning as the request was handled. + if ( + response.status === 302 && + response.headers.get('x-msw-intention') === 'passthrough' + ) { + emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return } @@ -115,21 +105,21 @@ Expected response resolver to return a mocked response Object, but got %s. The o // Store all the received response cookies in the virtual cookie store. readResponseCookies(request, response) - emitter.emit('request:match', request) + emitter.emit('request:match', { request, requestId }) const requiredLookupResult = - lookupResult as RequiredDeep + lookupResult.data as RequiredDeep const transformedResponse = handleRequestOptions?.transformResponse?.(response) || - (response as any as ResponseType) + (response as any as Response) handleRequestOptions?.onMockedResponse?.( transformedResponse, requiredLookupResult, ) - emitter.emit('request:end', request) + emitter.emit('request:end', { request, requestId }) return transformedResponse } diff --git a/src/core/utils/internal/Disposable.ts b/src/core/utils/internal/Disposable.ts new file mode 100644 index 000000000..ca61652ab --- /dev/null +++ b/src/core/utils/internal/Disposable.ts @@ -0,0 +1,9 @@ +export type DisposableSubscription = () => Promise | void + +export class Disposable { + protected subscriptions: Array = [] + + public async dispose() { + await Promise.all(this.subscriptions.map((subscription) => subscription())) + } +} diff --git a/src/utils/internal/checkGlobals.ts b/src/core/utils/internal/checkGlobals.ts similarity index 100% rename from src/utils/internal/checkGlobals.ts rename to src/core/utils/internal/checkGlobals.ts diff --git a/src/utils/internal/devUtils.ts b/src/core/utils/internal/devUtils.ts similarity index 100% rename from src/utils/internal/devUtils.ts rename to src/core/utils/internal/devUtils.ts diff --git a/src/utils/internal/getCallFrame.test.ts b/src/core/utils/internal/getCallFrame.test.ts similarity index 68% rename from src/utils/internal/getCallFrame.test.ts rename to src/core/utils/internal/getCallFrame.test.ts index e13c875ad..a61e30f7c 100644 --- a/src/utils/internal/getCallFrame.test.ts +++ b/src/core/utils/internal/getCallFrame.test.ts @@ -13,9 +13,9 @@ class ErrorWithStack extends Error { test('supports Node.js (Linux, MacOS) error stack', () => { const linuxError = new ErrorWithStack([ 'Error: ', - ' at getCallFrame (/Users/mock/github/msw/lib/umd/index.js:3735:22)', - ' at Object.get (/Users/mock/github/msw/lib/umd/index.js:3776:29)', - ' at Object. (/Users/mock/github/msw/test/msw-api/setup-server/printHandlers.test.ts:13:8)', // <-- this one + ' at getCallFrame (/Users/mock/github/msw/lib/node/index.js:3735:22)', + ' at Object.get (/Users/mock/github/msw/lib/node/index.js:3776:29)', + ' at Object. (/Users/mock/github/msw/test/msw-api/setup-server/listHandlers.test.ts:13:8)', // <-- this one ' at Runtime._execModule (/Users/mock/github/msw/node_modules/jest-runtime/build/index.js:1299:24)', ' at Runtime._loadModule (/Users/mock/github/msw/node_modules/jest-runtime/build/index.js:898:12)', ' at Runtime.requireModule (/Users/mock/github/msw/node_modules/jest-runtime/build/index.js:746:10)', @@ -24,15 +24,15 @@ test('supports Node.js (Linux, MacOS) error stack', () => { ' at runTest (/Users/mock/github/msw/node_modules/jest-runner/build/runTest.js:472:34)', ]) expect(getCallFrame(linuxError)).toEqual( - '/Users/mock/github/msw/test/msw-api/setup-server/printHandlers.test.ts:13:8', + '/Users/mock/github/msw/test/msw-api/setup-server/listHandlers.test.ts:13:8', ) const macOsError = new ErrorWithStack([ 'Error: ', - ' at getCallFrame (/Users/mock/git/msw/lib/umd/index.js:3735:22)', - ' at graphQLRequestHandler (/Users/mock/git/msw/lib/umd/index.js:7071:25)', - ' at Object.query (/Users/mock/git/msw/lib/umd/index.js:7182:18)', - ' at Object. (/Users/mock/git/msw/test/msw-api/setup-server/printHandlers.test.ts:14:11)', // <-- this one + ' at getCallFrame (/Users/mock/git/msw/lib/node/index.js:3735:22)', + ' at graphQLRequestHandler (/Users/mock/git/msw/lib/node/index.js:7071:25)', + ' at Object.query (/Users/mock/git/msw/lib/node/index.js:7182:18)', + ' at Object. (/Users/mock/git/msw/test/msw-api/setup-server/listHandlers.test.ts:14:11)', // <-- this one ' at Runtime._execModule (/Users/mock/git/msw/node_modules/jest-runtime/build/index.js:1299:24)', ' at Runtime._loadModule (/Users/mock/git/msw/node_modules/jest-runtime/build/index.js:898:12)', ' at Runtime.requireModule (/Users/mock/git/msw/node_modules/jest-runtime/build/index.js:746:10)', @@ -42,17 +42,17 @@ test('supports Node.js (Linux, MacOS) error stack', () => { ]) expect(getCallFrame(macOsError)).toEqual( - '/Users/mock/git/msw/test/msw-api/setup-server/printHandlers.test.ts:14:11', + '/Users/mock/git/msw/test/msw-api/setup-server/listHandlers.test.ts:14:11', ) }) test('supports Node.js (Windows) error stack', () => { const error = new ErrorWithStack([ 'Error: ', - ' at getCallFrame (C:\\Users\\mock\\git\\msw\\lib\\umd\\index.js:3735:22)', - ' at graphQLRequestHandler (C:\\Users\\mock\\git\\msw\\lib\\umd\\index.js:7071:25)', - ' at Object.query (C:\\Users\\mock\\git\\msw\\lib\\umd\\index.js:7182:18)', - ' at Object. (C:\\Users\\mock\\git\\msw\\test\\msw-api\\setup-server\\printHandlers.test.ts:75:13)', // <-- this one + ' at getCallFrame (C:\\Users\\mock\\git\\msw\\lib\\node\\index.js:3735:22)', + ' at graphQLRequestHandler (C:\\Users\\mock\\git\\msw\\lib\\node\\index.js:7071:25)', + ' at Object.query (C:\\Users\\mock\\git\\msw\\lib\\node\\index.js:7182:18)', + ' at Object. (C:\\Users\\mock\\git\\msw\\test\\msw-api\\setup-server\\listHandlers.test.ts:75:13)', // <-- this one ' at Object.asyncJestTest (C:\\Users\\mock\\git\\msw\\node_modules\\jest-jasmine2\\build\\jasmineAsyncInstall.js:106:37)', ' at C:\\Users\\mock\\git\\msw\\node_modules\\jest-jasmine2\\build\\queueRunner.js:45:12', ' at new Promise ()', @@ -61,17 +61,17 @@ test('supports Node.js (Windows) error stack', () => { ]) expect(getCallFrame(error)).toBe( - 'C:\\Users\\mock\\git\\msw\\test\\msw-api\\setup-server\\printHandlers.test.ts:75:13', + 'C:\\Users\\mock\\git\\msw\\test\\msw-api\\setup-server\\listHandlers.test.ts:75:13', ) }) test('supports Chrome and Edge error stack', () => { const error = new ErrorWithStack([ 'Error', - ' at getCallFrame (webpack:///./lib/esm/getCallFrame-deps.js?:272:20)', - ' at Object.eval [as get] (webpack:///./lib/esm/rest-deps.js?:1402:90)', - ' at eval (webpack:///./test/msw-api/setup-worker/printHandlers.mocks.ts?:6:113)', // <-- this one - ' at Module../test/msw-api/setup-worker/printHandlers.mocks.ts (http://localhost:59464/main.js:1376:1)', + ' at getCallFrame (webpack:///./lib/browser/getCallFrame-deps.js?:272:20)', + ' at Object.eval [as get] (webpack:///./lib/browser/rest-deps.js?:1402:90)', + ' at eval (webpack:///./test/msw-api/setup-worker/listHandlers.mocks.ts?:6:113)', // <-- this one + ' at Module../test/msw-api/setup-worker/listHandlers.mocks.ts (http://localhost:59464/main.js:1376:1)', ' at __webpack_require__ (http://localhost:59464/main.js:790:30)', ' at fn (http://localhost:59464/main.js:101:20)', ' at eval (webpack:///multi_(webpack)-dev-server/client?:4:18)', @@ -81,16 +81,16 @@ test('supports Chrome and Edge error stack', () => { ]) expect(getCallFrame(error)).toBe( - 'webpack:///./test/msw-api/setup-worker/printHandlers.mocks.ts?:6:113', + 'webpack:///./test/msw-api/setup-worker/listHandlers.mocks.ts?:6:113', ) }) test('supports Firefox (MacOS, Windows) error stack', () => { const error = new ErrorWithStack([ - 'getCallFrame@webpack:///./lib/esm/getCallFrame-deps.js?:272:20', - 'createRestHandler/<@webpack:///./lib/esm/rest-deps.js?:1402:90', - '@webpack:///./test/msw-api/setup-worker/printHandlers.mocks.ts?:6:113', // <-- this one - './test/msw-api/setup-worker/printHandlers.mocks.ts@http://localhost:59464/main.js:1376:1', + 'getCallFrame@webpack:///./lib/browser/getCallFrame-deps.js?:272:20', + 'createRestHandler/<@webpack:///./lib/browser/rest-deps.js?:1402:90', + '@webpack:///./test/msw-api/setup-worker/listHandlers.mocks.ts?:6:113', // <-- this one + './test/msw-api/setup-worker/listHandlers.mocks.ts@http://localhost:59464/main.js:1376:1', '__webpack_require__@http://localhost:59464/main.js:790:30', 'fn@http://localhost:59464/main.js:101:20', '@webpack:///multi_(webpack)-dev-server/client?:4:18', @@ -100,7 +100,7 @@ test('supports Firefox (MacOS, Windows) error stack', () => { ]) expect(getCallFrame(error)).toBe( - 'webpack:///./test/msw-api/setup-worker/printHandlers.mocks.ts?:6:113', + 'webpack:///./test/msw-api/setup-worker/listHandlers.mocks.ts?:6:113', ) }) @@ -110,7 +110,7 @@ test('supports Safari (MacOS) error stack', () => { '', 'eval code', 'eval@[native code]', - './test/msw-api/setup-worker/printHandlers.mocks.ts@http://localhost:59464/main.js:1376:5', // <-- this one + './test/msw-api/setup-worker/listHandlers.mocks.ts@http://localhost:59464/main.js:1376:5', // <-- this one '__webpack_require__@http://localhost:59464/main.js:790:34', 'fn@http://localhost:59464/main.js:101:39', 'eval code', @@ -122,7 +122,7 @@ test('supports Safari (MacOS) error stack', () => { ]) expect(getCallFrame(errorOne)).toBe( - './test/msw-api/setup-worker/printHandlers.mocks.ts@http://localhost:59464/main.js:1376:5', + './test/msw-api/setup-worker/listHandlers.mocks.ts@http://localhost:59464/main.js:1376:5', ) const errorTwo = new ErrorWithStack([ @@ -130,7 +130,7 @@ test('supports Safari (MacOS) error stack', () => { 'graphQLRequestHandler', 'eval code', 'eval@[native code]', - './test/msw-api/setup-worker/printHandlers.mocks.ts@http://localhost:56460/main.js:1376:5', // <-- this one + './test/msw-api/setup-worker/listHandlers.mocks.ts@http://localhost:56460/main.js:1376:5', // <-- this one '__webpack_require__@http://localhost:56460/main.js:790:34', 'fn@http://localhost:56460/main.js:101:39', 'eval code', @@ -142,7 +142,7 @@ test('supports Safari (MacOS) error stack', () => { ]) expect(getCallFrame(errorTwo)).toBe( - './test/msw-api/setup-worker/printHandlers.mocks.ts@http://localhost:56460/main.js:1376:5', + './test/msw-api/setup-worker/listHandlers.mocks.ts@http://localhost:56460/main.js:1376:5', ) }) diff --git a/src/utils/internal/getCallFrame.ts b/src/core/utils/internal/getCallFrame.ts similarity index 91% rename from src/utils/internal/getCallFrame.ts rename to src/core/utils/internal/getCallFrame.ts index 4e297d2ee..bee9e70f4 100644 --- a/src/utils/internal/getCallFrame.ts +++ b/src/core/utils/internal/getCallFrame.ts @@ -2,7 +2,7 @@ const SOURCE_FRAME = /[\/\\]msw[\/\\]src[\/\\](.+)/ const BUILD_FRAME = - /(node_modules)?[\/\\]lib[\/\\](umd|esm|iief|cjs)[\/\\]|^[^\/\\]*$/ + /(node_modules)?[\/\\]lib[\/\\](core|browser|node|native|iife)[\/\\]|^[^\/\\]*$/ /** * Return the stack trace frame of a function's invocation. diff --git a/src/utils/internal/isIterable.test.ts b/src/core/utils/internal/isIterable.test.ts similarity index 100% rename from src/utils/internal/isIterable.test.ts rename to src/core/utils/internal/isIterable.test.ts diff --git a/src/utils/internal/isIterable.ts b/src/core/utils/internal/isIterable.ts similarity index 100% rename from src/utils/internal/isIterable.ts rename to src/core/utils/internal/isIterable.ts diff --git a/src/utils/internal/isObject.test.ts b/src/core/utils/internal/isObject.test.ts similarity index 100% rename from src/utils/internal/isObject.test.ts rename to src/core/utils/internal/isObject.test.ts diff --git a/src/utils/internal/isObject.ts b/src/core/utils/internal/isObject.ts similarity index 100% rename from src/utils/internal/isObject.ts rename to src/core/utils/internal/isObject.ts diff --git a/src/utils/internal/isStringEqual.test.ts b/src/core/utils/internal/isStringEqual.test.ts similarity index 100% rename from src/utils/internal/isStringEqual.test.ts rename to src/core/utils/internal/isStringEqual.test.ts diff --git a/src/utils/internal/isStringEqual.ts b/src/core/utils/internal/isStringEqual.ts similarity index 100% rename from src/utils/internal/isStringEqual.ts rename to src/core/utils/internal/isStringEqual.ts diff --git a/src/utils/internal/jsonParse.test.ts b/src/core/utils/internal/jsonParse.test.ts similarity index 100% rename from src/utils/internal/jsonParse.test.ts rename to src/core/utils/internal/jsonParse.test.ts diff --git a/src/utils/internal/jsonParse.ts b/src/core/utils/internal/jsonParse.ts similarity index 100% rename from src/utils/internal/jsonParse.ts rename to src/core/utils/internal/jsonParse.ts diff --git a/src/utils/internal/mergeRight.test.ts b/src/core/utils/internal/mergeRight.test.ts similarity index 100% rename from src/utils/internal/mergeRight.test.ts rename to src/core/utils/internal/mergeRight.test.ts diff --git a/src/utils/internal/mergeRight.ts b/src/core/utils/internal/mergeRight.ts similarity index 100% rename from src/utils/internal/mergeRight.ts rename to src/core/utils/internal/mergeRight.ts diff --git a/src/core/utils/internal/parseGraphQLRequest.test.ts b/src/core/utils/internal/parseGraphQLRequest.test.ts new file mode 100644 index 000000000..7bb624d96 --- /dev/null +++ b/src/core/utils/internal/parseGraphQLRequest.test.ts @@ -0,0 +1,99 @@ +/** + * @jest-environment jsdom + */ +import { encodeBuffer } from '@mswjs/interceptors' +import { OperationTypeNode } from 'graphql' +import { + ParsedGraphQLRequest, + parseGraphQLRequest, +} from './parseGraphQLRequest' + +test('returns true given a GraphQL-compatible request', async () => { + const getRequest = new Request( + new URL( + 'http://localhost:8080/graphql?query=mutation Login { user { id } }', + ), + ) + expect(await parseGraphQLRequest(getRequest)).toEqual< + ParsedGraphQLRequest + >({ + operationType: OperationTypeNode.MUTATION, + operationName: 'Login', + query: `mutation Login { user { id } }`, + variables: undefined, + }) + + const postRequest = new Request(new URL('http://localhost:8080/graphql'), { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body: encodeBuffer( + JSON.stringify({ + query: `query GetUser { user { firstName } }`, + }), + ), + }) + + expect(await parseGraphQLRequest(postRequest)).toEqual< + ParsedGraphQLRequest + >({ + operationType: OperationTypeNode.QUERY, + operationName: 'GetUser', + query: `query GetUser { user { firstName } }`, + variables: undefined, + }) +}) + +test('throws an exception given an invalid GraphQL request', async () => { + const getRequest = new Request( + new URL('http://localhost:8080/graphql?query=mutation Login() { user { {}'), + ) + + await expect(parseGraphQLRequest(getRequest)).rejects.toThrowError( + '[MSW] Failed to intercept a GraphQL request to "GET http://localhost:8080/graphql": cannot parse query. See the error message from the parser below.', + ) + + const postRequest = new Request(new URL('http://localhost:8080/graphql'), { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body: encodeBuffer( + JSON.stringify({ + query: `query GetUser() { user {{}`, + }), + ), + }) + + await expect(parseGraphQLRequest(postRequest)).rejects.toThrowError( + '[MSW] Failed to intercept a GraphQL request to "POST http://localhost:8080/graphql": cannot parse query. See the error message from the parser below.\n\nSyntax Error: Expected "$", found ")".', + ) +}) + +test('returns false given a GraphQL-incompatible request', async () => { + const getRequest = new Request(new URL('http://localhost:8080/graphql'), { + headers: new Headers({ 'Content-Type': 'application/json' }), + }) + expect(await parseGraphQLRequest(getRequest)).toBeUndefined() + + const postRequest = new Request(new URL('http://localhost:8080/graphql'), { + method: 'POST', + headers: new Headers({ 'Content-Type': 'application/json' }), + body: encodeBuffer( + JSON.stringify({ + queryUser: true, + }), + ), + }) + expect(await parseGraphQLRequest(postRequest)).toBeUndefined() +}) + +test('does not read the original request body', async () => { + const request = new Request(new URL('http://localhost/api'), { + method: 'POST', + body: JSON.stringify({ payload: 'value' }), + }) + + await parseGraphQLRequest(request) + + // Must not read the original request body because GraphQL parsing + // is an internal operation that must not lock the body stream. + expect(request.bodyUsed).toBe(false) +}) diff --git a/src/utils/internal/parseGraphQLRequest.ts b/src/core/utils/internal/parseGraphQLRequest.ts similarity index 70% rename from src/utils/internal/parseGraphQLRequest.ts rename to src/core/utils/internal/parseGraphQLRequest.ts index b3d757040..230d145f0 100644 --- a/src/utils/internal/parseGraphQLRequest.ts +++ b/src/core/utils/internal/parseGraphQLRequest.ts @@ -4,11 +4,11 @@ import type { OperationTypeNode, } from 'graphql' import { parse } from 'graphql' -import { GraphQLVariables } from '../../handlers/GraphQLHandler' +import type { GraphQLVariables } from '../../handlers/GraphQLHandler' import { getPublicUrlFromRequest } from '../request/getPublicUrlFromRequest' -import { MockedRequest } from '../request/MockedRequest' import { devUtils } from './devUtils' import { jsonParse } from './jsonParse' +import { parseMultipartData } from './parseMultipartData' interface GraphQLInput { query: string | null @@ -24,13 +24,14 @@ export type ParsedGraphQLRequest< VariablesType extends GraphQLVariables = GraphQLVariables, > = | (ParsedGraphQLQuery & { + query: string variables?: VariablesType }) | undefined export function parseDocumentNode(node: DocumentNode): ParsedGraphQLQuery { - const operationDef = node.definitions.find((def) => { - return def.kind === 'OperationDefinition' + const operationDef = node.definitions.find((definition) => { + return definition.kind === 'OperationDefinition' }) as OperationDefinitionNode return { @@ -62,6 +63,7 @@ function extractMultipartVariables( files: Record, ) { const operations = { variables } + for (const [key, pathArray] of Object.entries(map)) { if (!(key in files)) { throw new Error(`Given files do not have a key '${key}' .`) @@ -83,14 +85,16 @@ function extractMultipartVariables( target[lastPath] = files[key] } } + return operations.variables } -function getGraphQLInput(request: MockedRequest): GraphQLInput | null { +async function getGraphQLInput(request: Request): Promise { switch (request.method) { case 'GET': { - const query = request.url.searchParams.get('query') - const variables = request.url.searchParams.get('variables') || '' + const url = new URL(request.url) + const query = url.searchParams.get('query') + const variables = url.searchParams.get('variables') || '' return { query, @@ -99,19 +103,24 @@ function getGraphQLInput(request: MockedRequest): GraphQLInput | null { } case 'POST': { - if (request.body?.query) { - const { query, variables } = request.body - - return { - query, - variables, + // Clone the request so we could read its body without locking + // the body stream to the downward consumers. + const requestClone = request.clone() + + // Handle multipart body GraphQL operations. + if ( + request.headers.get('content-type')?.includes('multipart/form-data') + ) { + const responseJson = parseMultipartData( + await requestClone.text(), + request.headers, + ) + + if (!responseJson) { + return null } - } - // Handle multipart body operations. - if (request.body?.operations) { - const { operations, map, ...files } = - request.body as GraphQLMultipartRequestBody + const { operations, map, ...files } = responseJson const parsedOperations = jsonParse<{ query?: string; variables?: GraphQLVariables }>( operations, @@ -135,6 +144,22 @@ function getGraphQLInput(request: MockedRequest): GraphQLInput | null { variables, } } + + // Handle plain POST GraphQL operations. + const requestJson: { + query: string + variables?: GraphQLVariables + operations?: any /** @todo Annotate this */ + } = await requestClone.json().catch(() => null) + + if (requestJson?.query) { + const { query, variables } = requestJson + + return { + query, + variables, + } + } } default: @@ -146,13 +171,13 @@ function getGraphQLInput(request: MockedRequest): GraphQLInput | null { * Determines if a given request can be considered a GraphQL request. * Does not parse the query and does not guarantee its validity. */ -export function parseGraphQLRequest( - request: MockedRequest, -): ParsedGraphQLRequest { - const input = getGraphQLInput(request) +export async function parseGraphQLRequest( + request: Request, +): Promise { + const input = await getGraphQLInput(request) if (!input || !input.query) { - return undefined + return } const { query, variables } = input @@ -172,6 +197,7 @@ export function parseGraphQLRequest( } return { + query: input.query, operationType: parsedResult.operationType, operationName: parsedResult.operationName, variables, diff --git a/src/utils/internal/parseMultipartData.test.ts b/src/core/utils/internal/parseMultipartData.test.ts similarity index 100% rename from src/utils/internal/parseMultipartData.test.ts rename to src/core/utils/internal/parseMultipartData.test.ts diff --git a/src/utils/internal/parseMultipartData.ts b/src/core/utils/internal/parseMultipartData.ts similarity index 100% rename from src/utils/internal/parseMultipartData.ts rename to src/core/utils/internal/parseMultipartData.ts diff --git a/src/utils/internal/pipeEvents.test.ts b/src/core/utils/internal/pipeEvents.test.ts similarity index 74% rename from src/utils/internal/pipeEvents.test.ts rename to src/core/utils/internal/pipeEvents.test.ts index 727a0cdc8..d5e9e51b3 100644 --- a/src/utils/internal/pipeEvents.test.ts +++ b/src/core/utils/internal/pipeEvents.test.ts @@ -1,9 +1,9 @@ -import { EventEmitter } from 'stream' +import { Emitter } from 'strict-event-emitter' import { pipeEvents } from './pipeEvents' it('pipes events from the source emitter to the destination emitter', () => { - const source = new EventEmitter() - const destination = new EventEmitter() + const source = new Emitter() + const destination = new Emitter() pipeEvents(source, destination) const callback = jest.fn() diff --git a/src/utils/internal/pipeEvents.ts b/src/core/utils/internal/pipeEvents.ts similarity index 100% rename from src/utils/internal/pipeEvents.ts rename to src/core/utils/internal/pipeEvents.ts diff --git a/src/utils/internal/requestHandlerUtils.ts b/src/core/utils/internal/requestHandlerUtils.ts similarity index 52% rename from src/utils/internal/requestHandlerUtils.ts rename to src/core/utils/internal/requestHandlerUtils.ts index 10d18952d..2b50fa29f 100644 --- a/src/utils/internal/requestHandlerUtils.ts +++ b/src/core/utils/internal/requestHandlerUtils.ts @@ -1,21 +1,21 @@ import { RequestHandler } from '../../handlers/RequestHandler' export function use( - currentHandlers: RequestHandler[], - ...handlers: RequestHandler[] + currentHandlers: Array, + ...handlers: Array ): void { currentHandlers.unshift(...handlers) } -export function restoreHandlers(handlers: RequestHandler[]): void { +export function restoreHandlers(handlers: Array): void { handlers.forEach((handler) => { - handler.markAsSkipped(false) + handler.isUsed = false }) } export function resetHandlers( - initialHandlers: RequestHandler[], - ...nextHandlers: RequestHandler[] + initialHandlers: Array, + ...nextHandlers: Array ) { return nextHandlers.length > 0 ? [...nextHandlers] : [...initialHandlers] } diff --git a/src/utils/internal/toReadonlyArray.test.ts b/src/core/utils/internal/toReadonlyArray.test.ts similarity index 100% rename from src/utils/internal/toReadonlyArray.test.ts rename to src/core/utils/internal/toReadonlyArray.test.ts diff --git a/src/utils/internal/toReadonlyArray.ts b/src/core/utils/internal/toReadonlyArray.ts similarity index 100% rename from src/utils/internal/toReadonlyArray.ts rename to src/core/utils/internal/toReadonlyArray.ts diff --git a/src/utils/internal/tryCatch.test.ts b/src/core/utils/internal/tryCatch.test.ts similarity index 100% rename from src/utils/internal/tryCatch.test.ts rename to src/core/utils/internal/tryCatch.test.ts diff --git a/src/utils/internal/tryCatch.ts b/src/core/utils/internal/tryCatch.ts similarity index 100% rename from src/utils/internal/tryCatch.ts rename to src/core/utils/internal/tryCatch.ts diff --git a/src/core/utils/internal/uuidv4.ts b/src/core/utils/internal/uuidv4.ts new file mode 100644 index 000000000..5daf9d0cc --- /dev/null +++ b/src/core/utils/internal/uuidv4.ts @@ -0,0 +1,3 @@ +export function uuidv4(): string { + return Math.random().toString(16).slice(2) +} diff --git a/src/utils/logging/getStatusCodeColor.test.ts b/src/core/utils/logging/getStatusCodeColor.test.ts similarity index 100% rename from src/utils/logging/getStatusCodeColor.test.ts rename to src/core/utils/logging/getStatusCodeColor.test.ts diff --git a/src/utils/logging/getStatusCodeColor.ts b/src/core/utils/logging/getStatusCodeColor.ts similarity index 100% rename from src/utils/logging/getStatusCodeColor.ts rename to src/core/utils/logging/getStatusCodeColor.ts diff --git a/src/utils/logging/getTimestamp.test.ts b/src/core/utils/logging/getTimestamp.test.ts similarity index 100% rename from src/utils/logging/getTimestamp.test.ts rename to src/core/utils/logging/getTimestamp.test.ts diff --git a/src/utils/logging/getTimestamp.ts b/src/core/utils/logging/getTimestamp.ts similarity index 100% rename from src/utils/logging/getTimestamp.ts rename to src/core/utils/logging/getTimestamp.ts 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/utils/matching/matchRequestUrl.test.ts b/src/core/utils/matching/matchRequestUrl.test.ts similarity index 100% rename from src/utils/matching/matchRequestUrl.test.ts rename to src/core/utils/matching/matchRequestUrl.test.ts diff --git a/src/utils/matching/matchRequestUrl.ts b/src/core/utils/matching/matchRequestUrl.ts similarity index 96% rename from src/utils/matching/matchRequestUrl.ts rename to src/core/utils/matching/matchRequestUrl.ts index 8d5d25cfc..3b9ce6ebf 100644 --- a/src/utils/matching/matchRequestUrl.ts +++ b/src/core/utils/matching/matchRequestUrl.ts @@ -1,5 +1,5 @@ import { match } from 'path-to-regexp' -import { getCleanUrl } from '@mswjs/interceptors/lib/utils/getCleanUrl.js' +import { getCleanUrl } from '@mswjs/interceptors' import { normalizePath } from './normalizePath' export type Path = string | RegExp diff --git a/src/utils/matching/normalizePath.node.test.ts b/src/core/utils/matching/normalizePath.node.test.ts similarity index 100% rename from src/utils/matching/normalizePath.node.test.ts rename to src/core/utils/matching/normalizePath.node.test.ts diff --git a/src/utils/matching/normalizePath.test.ts b/src/core/utils/matching/normalizePath.test.ts similarity index 100% rename from src/utils/matching/normalizePath.test.ts rename to src/core/utils/matching/normalizePath.test.ts diff --git a/src/utils/matching/normalizePath.ts b/src/core/utils/matching/normalizePath.ts similarity index 100% rename from src/utils/matching/normalizePath.ts rename to src/core/utils/matching/normalizePath.ts diff --git a/src/core/utils/request/getPublicUrlFromRequest.test.ts b/src/core/utils/request/getPublicUrlFromRequest.test.ts new file mode 100644 index 000000000..3ac30af83 --- /dev/null +++ b/src/core/utils/request/getPublicUrlFromRequest.test.ts @@ -0,0 +1,26 @@ +/** + * @jest-environment jsdom + */ +import { getPublicUrlFromRequest } from './getPublicUrlFromRequest' + +test('returns an absolute request URL withouth search params', () => { + expect( + getPublicUrlFromRequest(new Request(new URL('https://test.mswjs.io/path'))), + ).toBe('https://test.mswjs.io/path') + + expect( + getPublicUrlFromRequest(new Request(new URL('http://localhost/path'))), + ).toBe('/path') + + expect( + getPublicUrlFromRequest( + new Request(new URL('http://localhost/path?foo=bar')), + ), + ).toBe('/path') +}) + +it('returns a relative URL given the request to the same origin', () => { + expect(getPublicUrlFromRequest(new Request('http://localhost/user'))).toBe( + '/user', + ) +}) diff --git a/src/core/utils/request/getPublicUrlFromRequest.ts b/src/core/utils/request/getPublicUrlFromRequest.ts new file mode 100644 index 000000000..63bae1014 --- /dev/null +++ b/src/core/utils/request/getPublicUrlFromRequest.ts @@ -0,0 +1,15 @@ +/** + * Returns a relative URL if the given request URL is relative to the current origin. + * Otherwise returns an absolute URL. + */ +export function getPublicUrlFromRequest(request: Request): string { + if (typeof location === 'undefined') { + return request.url + } + + const url = new URL(request.url) + + return url.origin === location.origin + ? url.pathname + : url.origin + url.pathname +} diff --git a/src/utils/request/getRequestCookies.node.test.ts b/src/core/utils/request/getRequestCookies.node.test.ts similarity index 86% rename from src/utils/request/getRequestCookies.node.test.ts rename to src/core/utils/request/getRequestCookies.node.test.ts index 185af3264..2061c4fe0 100644 --- a/src/utils/request/getRequestCookies.node.test.ts +++ b/src/core/utils/request/getRequestCookies.node.test.ts @@ -2,7 +2,6 @@ * @jest-environment node */ import { getRequestCookies } from './getRequestCookies' -import { MockedRequest } from './MockedRequest' const prevLocation = global.location @@ -21,7 +20,7 @@ afterAll(() => { test('returns empty object when in a node environment with polyfilled location object', () => { const cookies = getRequestCookies( - new MockedRequest(new URL('/user', location.origin), { + new Request(new URL('/user', location.href), { credentials: 'include', }), ) diff --git a/src/utils/request/getRequestCookies.test.ts b/src/core/utils/request/getRequestCookies.test.ts similarity index 77% rename from src/utils/request/getRequestCookies.test.ts rename to src/core/utils/request/getRequestCookies.test.ts index 42ee3676f..59a5c5b7b 100644 --- a/src/utils/request/getRequestCookies.test.ts +++ b/src/core/utils/request/getRequestCookies.test.ts @@ -2,8 +2,7 @@ * @jest-environment jsdom */ import { getRequestCookies } from './getRequestCookies' -import { clearCookies } from '../../../test/support/utils' -import { MockedRequest } from './MockedRequest' +import { clearCookies } from '../../../../test/support/utils' beforeAll(() => { // Emulate some `document.cookie` value. @@ -17,7 +16,7 @@ afterAll(() => { test('returns all document cookies given "include" credentials', () => { const cookies = getRequestCookies( - new MockedRequest(new URL('/user', location.origin), { + new Request(new URL('/user', location.origin), { credentials: 'include', }), ) @@ -30,7 +29,7 @@ test('returns all document cookies given "include" credentials', () => { test('returns all document cookies given "same-origin" credentials and the same request origin', () => { const cookies = getRequestCookies( - new MockedRequest(new URL('/user', location.origin), { + new Request(new URL('/user', location.origin), { credentials: 'same-origin', }), ) @@ -43,7 +42,7 @@ test('returns all document cookies given "same-origin" credentials and the same test('returns an empty object given "same-origin" credentials and a different request origin', () => { const cookies = getRequestCookies( - new MockedRequest(new URL('https://test.mswjs.io/user'), { + new Request(new URL('https://test.mswjs.io/user'), { credentials: 'same-origin', }), ) @@ -53,7 +52,7 @@ test('returns an empty object given "same-origin" credentials and a different re test('returns an empty object given "omit" credentials', () => { const cookies = getRequestCookies( - new MockedRequest(new URL('/user', location.origin), { + new Request(new URL('/user', location.origin), { credentials: 'omit', }), ) diff --git a/src/core/utils/request/getRequestCookies.ts b/src/core/utils/request/getRequestCookies.ts new file mode 100644 index 000000000..749390ee2 --- /dev/null +++ b/src/core/utils/request/getRequestCookies.ts @@ -0,0 +1,76 @@ +import cookieUtils from '@bundled-es-modules/cookie' +import { store } from '@mswjs/cookies' + +function getAllDocumentCookies() { + return cookieUtils.parse(document.cookie) +} + +/** @todo Rename this to "getDocumentCookies" */ +/** + * Returns relevant document cookies based on the request `credentials` option. + */ +export function getRequestCookies(request: Request): Record { + /** + * @note No cookies persist on the document in Node.js: no document. + */ + if (typeof document === 'undefined' || typeof location === 'undefined') { + return {} + } + + switch (request.credentials) { + case 'same-origin': { + const url = new URL(request.url) + + // Return document cookies only when requested a resource + // from the same origin as the current document. + return location.origin === url.origin ? getAllDocumentCookies() : {} + } + + case 'include': { + // Return all document cookies. + return getAllDocumentCookies() + } + + default: { + return {} + } + } +} + +export function getAllRequestCookies(request: Request): Record { + const requestCookiesString = request.headers.get('cookie') + const cookiesFromHeaders = requestCookiesString + ? cookieUtils.parse(requestCookiesString) + : {} + + store.hydrate() + + const cookiesFromStore = Array.from(store.get(request)?.entries()).reduce( + (cookies, [name, { value }]) => { + return Object.assign(cookies, { [name.trim()]: value }) + }, + {}, + ) + + const cookiesFromDocument = getRequestCookies(request) + + const forwardedCookies = { + ...cookiesFromDocument, + ...cookiesFromStore, + } + + // Set the inferred cookies from the cookie store and the document + // on the request's headers. + /** + * @todo Consider making this a separate step so this function + * is pure-er. + */ + for (const [name, value] of Object.entries(forwardedCookies)) { + request.headers.append('cookie', `${name}=${value}`) + } + + return { + ...forwardedCookies, + ...cookiesFromHeaders, + } +} diff --git a/src/utils/request/onUnhandledRequest.test.ts b/src/core/utils/request/onUnhandledRequest.test.ts similarity index 51% rename from src/utils/request/onUnhandledRequest.test.ts rename to src/core/utils/request/onUnhandledRequest.test.ts index 3d6de6e75..5649ea866 100644 --- a/src/utils/request/onUnhandledRequest.test.ts +++ b/src/core/utils/request/onUnhandledRequest.test.ts @@ -1,34 +1,36 @@ +/** + * @jest-environment jsdom + */ import { onUnhandledRequest, UnhandledRequestCallback, } from './onUnhandledRequest' -import { RestHandler, RESTMethods } from '../../handlers/RestHandler' +import { HttpHandler, HttpMethods } from '../../handlers/HttpHandler' import { ResponseResolver } from '../../handlers/RequestHandler' -import { MockedRequest } from './MockedRequest' 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 http://localhost/api + • GET /api If you still wish to intercept this unhandled request, please create a request handler for it. 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 http://localhost/api + • GET /api If you still wish to intercept this unhandled request, please create a request handler for it. 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 http://localhost/api + • GET /api Did you mean to request one of the following resources instead? @@ -47,9 +49,9 @@ afterEach(() => { jest.restoreAllMocks() }) -test('supports the "bypass" request strategy', () => { - onUnhandledRequest( - new MockedRequest(new URL('http://localhost/api')), +test('supports the "bypass" request strategy', async () => { + await onUnhandledRequest( + new Request(new URL('http://localhost/api')), [], 'bypass', ) @@ -58,9 +60,9 @@ test('supports the "bypass" request strategy', () => { expect(console.error).not.toHaveBeenCalled() }) -test('supports the "warn" request strategy', () => { - onUnhandledRequest( - new MockedRequest(new URL('http://localhost/api')), +test('supports the "warn" request strategy', async () => { + await onUnhandledRequest( + new Request(new URL('http://localhost/api')), [], 'warn', ) @@ -68,28 +70,28 @@ test('supports the "warn" request strategy', () => { expect(console.warn).toHaveBeenCalledWith(fixtures.warningWithoutSuggestions) }) -test('supports the "error" request strategy', () => { - expect(() => +test('supports the "error" request strategy', async () => { + await expect( onUnhandledRequest( - new MockedRequest(new URL('http://localhost/api')), + new Request(new URL('http://localhost/api')), [], 'error', ), - ).toThrow( + ).rejects.toThrow( '[MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.', ) expect(console.error).toHaveBeenCalledWith(fixtures.errorWithoutSuggestions) }) -test('supports a custom callback function', () => { +test('supports a custom callback function', async () => { const callback = jest.fn>( (request) => { - console.warn(`callback: ${request.method} ${request.url.href}`) + console.warn(`callback: ${request.method} ${request.url}`) }, ) - const request = new MockedRequest(new URL('/user', 'http://localhost:3000')) - onUnhandledRequest(request, [], callback) + const request = new Request(new URL('/user', 'http://localhost:3000')) + await onUnhandledRequest(request, [], callback) expect(callback).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledWith(request, { @@ -103,17 +105,15 @@ test('supports a custom callback function', () => { ) }) -test('supports calling default strategies from the custom callback function', () => { +test('supports calling default strategies from the custom callback function', async () => { const callback = jest.fn>( (request, print) => { - console.warn(`custom callback: ${request.id}`) - // Call the default "error" strategy. print.error() }, ) - const request = new MockedRequest(new URL('http://localhost/api')) - expect(() => onUnhandledRequest(request, [], callback)).toThrow( + const request = new Request(new URL('http://localhost/api')) + await expect(onUnhandledRequest(request, [], callback)).rejects.toThrow( `[MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.`, ) @@ -123,16 +123,13 @@ test('supports calling default strategies from the custom callback function', () error: expect.any(Function), }) - // Check that the custom logic in the callback was called. - expect(console.warn).toHaveBeenCalledWith(`custom callback: ${request.id}`) - // Check that the default strategy was called. expect(console.error).toHaveBeenCalledWith(fixtures.errorWithoutSuggestions) }) -test('does not print any suggestions given no handlers to suggest', () => { - onUnhandledRequest( - new MockedRequest(new URL('http://localhost/api')), +test('does not print any suggestions given no handlers to suggest', async () => { + await onUnhandledRequest( + new Request(new URL('http://localhost/api')), [], 'warn', ) @@ -140,14 +137,14 @@ test('does not print any suggestions given no handlers to suggest', () => { expect(console.warn).toHaveBeenCalledWith(fixtures.warningWithoutSuggestions) }) -test('does not print any suggestions given no handlers are similar', () => { - onUnhandledRequest( - new MockedRequest(new URL('http://localhost/api')), +test('does not print any suggestions given no handlers are similar', async () => { + await onUnhandledRequest( + new Request(new URL('http://localhost/api')), [ // None of the defined request handlers match the actual request URL // to be used as suggestions. - new RestHandler(RESTMethods.GET, 'https://api.github.com', resolver), - new RestHandler(RESTMethods.GET, 'https://api.stripe.com', resolver), + new HttpHandler(HttpMethods.GET, 'https://api.github.com', resolver), + new HttpHandler(HttpMethods.GET, 'https://api.stripe.com', resolver), ], 'warn', ) @@ -155,66 +152,66 @@ test('does not print any suggestions given no handlers are similar', () => { expect(console.warn).toHaveBeenCalledWith(fixtures.warningWithoutSuggestions) }) -test('respects RegExp as a request handler method', () => { - onUnhandledRequest( - new MockedRequest(new URL('http://localhost/api')), - [new RestHandler(/^GE/, 'http://localhost/api', resolver)], +test('respects RegExp as a request handler method', async () => { + await onUnhandledRequest( + new Request(new URL('http://localhost/api')), + [new HttpHandler(/^GE/, 'http://localhost/api', resolver)], 'warn', ) expect(console.warn).toHaveBeenCalledWith(fixtures.warningWithoutSuggestions) }) -test('sorts the suggestions by relevance', () => { - onUnhandledRequest( - new MockedRequest(new URL('http://localhost/api')), +test('sorts the suggestions by relevance', async () => { + await onUnhandledRequest( + new Request(new URL('http://localhost/api')), [ - new RestHandler(RESTMethods.GET, 'http://localhost/', resolver), - new RestHandler(RESTMethods.GET, 'http://localhost:9090/api', resolver), - new RestHandler(RESTMethods.POST, 'http://localhost/api', resolver), + new HttpHandler(HttpMethods.GET, '/', resolver), + new HttpHandler(HttpMethods.GET, 'https://api.example.com/api', resolver), + new HttpHandler(HttpMethods.POST, '/api', resolver), ], 'warn', ) expect(console.warn).toHaveBeenCalledWith( fixtures.warningWithSuggestions(`\ - • POST http://localhost/api - • GET http://localhost/`), + • POST /api + • GET /`), ) }) -test('does not print more than 4 suggestions', () => { - onUnhandledRequest( - new MockedRequest(new URL('http://localhost/api')), +test('does not print more than 4 suggestions', async () => { + await onUnhandledRequest( + new Request(new URL('http://localhost/api')), [ - new RestHandler(RESTMethods.GET, 'http://localhost/ap', resolver), - new RestHandler(RESTMethods.GET, 'http://localhost/api', resolver), - new RestHandler(RESTMethods.GET, 'http://localhost/api-1', resolver), - new RestHandler(RESTMethods.GET, 'http://localhost/api-2', resolver), - new RestHandler(RESTMethods.GET, 'http://localhost/api-3', resolver), - new RestHandler(RESTMethods.GET, 'http://localhost/api-4', resolver), + new HttpHandler(HttpMethods.GET, '/ap', resolver), + new HttpHandler(HttpMethods.GET, '/api', resolver), + new HttpHandler(HttpMethods.GET, '/api-1', resolver), + new HttpHandler(HttpMethods.GET, '/api-2', resolver), + new HttpHandler(HttpMethods.GET, '/api-3', resolver), + new HttpHandler(HttpMethods.GET, '/api-4', resolver), ], 'warn', ) expect(console.warn).toHaveBeenCalledWith( fixtures.warningWithSuggestions(`\ - • GET http://localhost/api - • GET http://localhost/ap - • GET http://localhost/api-1 - • GET http://localhost/api-2`), + • GET /api + • GET /ap + • GET /api-1 + • GET /api-2`), ) }) -test('throws an exception given unknown request strategy', () => { - expect(() => +test('throws an exception given unknown request strategy', async () => { + await expect( onUnhandledRequest( - new MockedRequest(new URL('http://localhost/api')), + new Request(new URL('http://localhost/api')), [], // @ts-expect-error Intentional unknown strategy. - 'arbitrary-strategy', + 'invalid-strategy', ), - ).toThrow( - '[MSW] Failed to react to an unhandled request: unknown strategy "arbitrary-strategy". Please provide one of the supported strategies ("bypass", "warn", "error") or a custom callback function as the value of the "onUnhandledRequest" option.', + ).rejects.toThrow( + '[MSW] Failed to react to an unhandled request: unknown strategy "invalid-strategy". Please provide one of the supported strategies ("bypass", "warn", "error") or a custom callback function as the value of the "onUnhandledRequest" option.', ) }) diff --git a/src/utils/request/onUnhandledRequest.ts b/src/core/utils/request/onUnhandledRequest.ts similarity index 77% rename from src/utils/request/onUnhandledRequest.ts rename to src/core/utils/request/onUnhandledRequest.ts index 96432e150..89a571360 100644 --- a/src/utils/request/onUnhandledRequest.ts +++ b/src/core/utils/request/onUnhandledRequest.ts @@ -1,16 +1,16 @@ -import getStringMatchScore from 'js-levenshtein' +// @ts-ignore +import jsLevenshtein from '@bundled-es-modules/js-levenshtein' +import { RequestHandler, HttpHandler, GraphQLHandler } from '../..' import { ParsedGraphQLQuery, + ParsedGraphQLRequest, parseGraphQLRequest, } from '../internal/parseGraphQLRequest' import { getPublicUrlFromRequest } from './getPublicUrlFromRequest' import { isStringEqual } from '../internal/isStringEqual' -import { RestHandler } from '../../handlers/RestHandler' -import { GraphQLHandler } from '../../handlers/GraphQLHandler' -import { RequestHandler } from '../../handlers/RequestHandler' -import { tryCatch } from '../internal/tryCatch' import { devUtils } from '../internal/devUtils' -import { MockedRequest } from './MockedRequest' + +const getStringMatchScore = jsLevenshtein const MAX_MATCH_SCORE = 3 const MAX_SUGGESTION_COUNT = 4 @@ -22,7 +22,7 @@ export interface UnhandledRequestPrint { } export type UnhandledRequestCallback = ( - request: MockedRequest, + request: Request, print: UnhandledRequestPrint, ) => void @@ -33,15 +33,17 @@ export type UnhandledRequestStrategy = | UnhandledRequestCallback interface RequestHandlerGroups { - rest: RestHandler[] - graphql: GraphQLHandler[] + http: Array + graphql: Array } -function groupHandlersByType(handlers: RequestHandler[]): RequestHandlerGroups { +function groupHandlersByType( + handlers: Array, +): RequestHandlerGroups { return handlers.reduce( (groups, handler) => { - if (handler instanceof RestHandler) { - groups.rest.push(handler) + if (handler instanceof HttpHandler) { + groups.http.push(handler) } if (handler instanceof GraphQLHandler) { @@ -51,7 +53,7 @@ function groupHandlersByType(handlers: RequestHandler[]): RequestHandlerGroups { return groups }, { - rest: [], + http: [], graphql: [], }, ) @@ -60,11 +62,11 @@ function groupHandlersByType(handlers: RequestHandler[]): RequestHandlerGroups { type RequestHandlerSuggestion = [number, RequestHandler] type ScoreGetterFn = ( - request: MockedRequest, + request: Request, handler: RequestHandlerType, ) => number -function getRestHandlerScore(): ScoreGetterFn { +function getHttpHandlerScore(): ScoreGetterFn { return (request, handler) => { const { path, method } = handler.info @@ -107,12 +109,12 @@ function getGraphQLHandlerScore( } function getSuggestedHandler( - request: MockedRequest, - handlers: RestHandler[] | GraphQLHandler[], - getScore: ScoreGetterFn | ScoreGetterFn, -): RequestHandler[] { - const suggestedHandlers = (handlers as RequestHandler[]) - .reduce((suggestions, handler) => { + request: Request, + handlers: Array | Array, + getScore: ScoreGetterFn | ScoreGetterFn, +): Array { + const suggestedHandlers = (handlers as Array) + .reduce>((suggestions, handler) => { const score = getScore(request, handler as any) return suggestions.concat([[score, handler]]) }, []) @@ -135,12 +137,15 @@ ${handlers.map((handler) => ` • ${handler.info.header}`).join('\n')}` return `Did you mean to request "${handlers[0].info.header}" instead?` } -export function onUnhandledRequest( - request: MockedRequest, - handlers: RequestHandler[], +export async function onUnhandledRequest( + request: Request, + handlers: Array, strategy: UnhandledRequestStrategy = 'warn', -): void { - const parsedGraphQLQuery = tryCatch(() => parseGraphQLRequest(request)) +): Promise { + const parsedGraphQLQuery = await parseGraphQLRequest(request).catch( + () => null, + ) + const publicUrl = getPublicUrlFromRequest(request) function generateHandlerSuggestion(): string { /** @@ -151,14 +156,14 @@ export function onUnhandledRequest( const handlerGroups = groupHandlersByType(handlers) const relevantHandlers = parsedGraphQLQuery ? handlerGroups.graphql - : handlerGroups.rest + : handlerGroups.http const suggestedHandlers = getSuggestedHandler( request, relevantHandlers, parsedGraphQLQuery ? getGraphQLHandlerScore(parsedGraphQLQuery) - : getRestHandlerScore(), + : getHttpHandlerScore(), ) return suggestedHandlers.length > 0 @@ -166,15 +171,24 @@ export function onUnhandledRequest( : '' } + function getGraphQLRequestHeader( + parsedGraphQLRequest: ParsedGraphQLRequest, + ): string { + if (!parsedGraphQLRequest?.operationName) { + return `anonymous ${parsedGraphQLRequest?.operationType} (${request.method} ${publicUrl})` + } + + return `${parsedGraphQLRequest.operationType} ${parsedGraphQLRequest.operationName} (${request.method} ${publicUrl})` + } + function generateUnhandledRequestMessage(): string { - const publicUrl = getPublicUrlFromRequest(request) const requestHeader = parsedGraphQLQuery - ? `${parsedGraphQLQuery.operationType} ${parsedGraphQLQuery.operationName} (${request.method} ${publicUrl})` + ? getGraphQLRequestHeader(parsedGraphQLQuery) : `${request.method} ${publicUrl}` 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/utils/request/readResponseCookies.ts b/src/core/utils/request/readResponseCookies.ts similarity index 51% rename from src/utils/request/readResponseCookies.ts rename to src/core/utils/request/readResponseCookies.ts index e57497dae..0e01b6137 100644 --- a/src/utils/request/readResponseCookies.ts +++ b/src/core/utils/request/readResponseCookies.ts @@ -1,11 +1,9 @@ import { store } from '@mswjs/cookies' -import { MockedResponse } from '../../response' -import { MockedRequest } from './MockedRequest' export function readResponseCookies( - request: MockedRequest, - response: MockedResponse, -) { + request: Request, + response: Response, +): void { store.add({ ...request, url: request.url.toString() }, response) store.persist() } diff --git a/src/core/utils/toResponseInit.ts b/src/core/utils/toResponseInit.ts new file mode 100644 index 000000000..e7e6f9a7a --- /dev/null +++ b/src/core/utils/toResponseInit.ts @@ -0,0 +1,7 @@ +export function toResponseInit(response: Response): ResponseInit { + return { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + } +} diff --git a/src/utils/url/cleanUrl.test.ts b/src/core/utils/url/cleanUrl.test.ts similarity index 100% rename from src/utils/url/cleanUrl.test.ts rename to src/core/utils/url/cleanUrl.test.ts diff --git a/src/utils/url/cleanUrl.ts b/src/core/utils/url/cleanUrl.ts similarity index 100% rename from src/utils/url/cleanUrl.ts rename to src/core/utils/url/cleanUrl.ts diff --git a/src/utils/url/getAbsoluteUrl.node.test.ts b/src/core/utils/url/getAbsoluteUrl.node.test.ts similarity index 100% rename from src/utils/url/getAbsoluteUrl.node.test.ts rename to src/core/utils/url/getAbsoluteUrl.node.test.ts diff --git a/src/utils/url/getAbsoluteUrl.test.ts b/src/core/utils/url/getAbsoluteUrl.test.ts similarity index 100% rename from src/utils/url/getAbsoluteUrl.test.ts rename to src/core/utils/url/getAbsoluteUrl.test.ts diff --git a/src/utils/url/getAbsoluteUrl.ts b/src/core/utils/url/getAbsoluteUrl.ts similarity index 100% rename from src/utils/url/getAbsoluteUrl.ts rename to src/core/utils/url/getAbsoluteUrl.ts diff --git a/src/utils/url/isAbsoluteUrl.test.ts b/src/core/utils/url/isAbsoluteUrl.test.ts similarity index 100% rename from src/utils/url/isAbsoluteUrl.test.ts rename to src/core/utils/url/isAbsoluteUrl.test.ts diff --git a/src/utils/url/isAbsoluteUrl.ts b/src/core/utils/url/isAbsoluteUrl.ts similarity index 100% rename from src/utils/url/isAbsoluteUrl.ts rename to src/core/utils/url/isAbsoluteUrl.ts diff --git a/src/graphql.ts b/src/graphql.ts deleted file mode 100644 index 7f971bdf6..000000000 --- a/src/graphql.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { DocumentNode, OperationTypeNode } from 'graphql' -import { ResponseResolver } from './handlers/RequestHandler' -import { - GraphQLHandler, - GraphQLContext, - GraphQLRequest, - GraphQLVariables, - ExpectedOperationTypeNode, - GraphQLHandlerNameSelector, -} from './handlers/GraphQLHandler' -import { Path } from './utils/matching/matchRequestUrl' - -export interface TypedDocumentNode< - Result = { [key: string]: any }, - Variables = { [key: string]: any }, -> extends DocumentNode { - __apiType?: (variables: Variables) => Result - __resultType?: Result - __variablesType?: Variables -} - -function createScopedGraphQLHandler( - operationType: ExpectedOperationTypeNode, - url: Path, -) { - return < - Query extends Record, - Variables extends GraphQLVariables = GraphQLVariables, - >( - operationName: - | GraphQLHandlerNameSelector - | DocumentNode - | TypedDocumentNode, - resolver: ResponseResolver< - GraphQLRequest, - GraphQLContext - >, - ) => { - return new GraphQLHandler>( - operationType, - operationName, - url, - resolver, - ) - } -} - -function createGraphQLOperationHandler(url: Path) { - return < - Query extends Record, - Variables extends GraphQLVariables = GraphQLVariables, - >( - resolver: ResponseResolver< - GraphQLRequest, - GraphQLContext - >, - ) => { - return new GraphQLHandler>( - 'all', - new RegExp('.*'), - url, - resolver, - ) - } -} - -const standardGraphQLHandlers = { - /** - * Captures any GraphQL operation, regardless of its name, under the current scope. - * @example - * graphql.operation((req, res, ctx) => { - * return res(ctx.data({ name: 'John' })) - * }) - * @see {@link https://mswjs.io/docs/api/graphql/operation `graphql.operation()`} - */ - operation: createGraphQLOperationHandler('*'), - - /** - * Captures a GraphQL query by a given name. - * @example - * graphql.query('GetUser', (req, res, ctx) => { - * return res(ctx.data({ user: { name: 'John' } })) - * }) - * @see {@link https://mswjs.io/docs/api/graphql/query `graphql.query()`} - */ - query: createScopedGraphQLHandler('query' as OperationTypeNode, '*'), - - /** - * Captures a GraphQL mutation by a given name. - * @example - * graphql.mutation('SavePost', (req, res, ctx) => { - * return res(ctx.data({ post: { id: 'abc-123' } })) - * }) - * @see {@link https://mswjs.io/docs/api/graphql/mutation `graphql.mutation()`} - */ - mutation: createScopedGraphQLHandler('mutation' as OperationTypeNode, '*'), -} - -function createGraphQLLink(url: Path): typeof standardGraphQLHandlers { - return { - operation: createGraphQLOperationHandler(url), - query: createScopedGraphQLHandler('query' as OperationTypeNode, url), - mutation: createScopedGraphQLHandler('mutation' as OperationTypeNode, url), - } -} - -export const graphql = { - ...standardGraphQLHandlers, - link: createGraphQLLink, -} diff --git a/src/handlers/RequestHandler.ts b/src/handlers/RequestHandler.ts deleted file mode 100644 index 3d0e5bd10..000000000 --- a/src/handlers/RequestHandler.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { Headers } from 'headers-polyfill' -import { - MaybePromise, - MockedResponse, - response, - ResponseComposition, -} from '../response' -import { getCallFrame } from '../utils/internal/getCallFrame' -import { isIterable } from '../utils/internal/isIterable' -import { status } from '../context/status' -import { set } from '../context/set' -import { delay } from '../context/delay' -import { fetch } from '../context/fetch' -import { ResponseResolutionContext } from '../utils/getResponse' -import { SerializedResponse } from '../setupWorker/glossary' -import { MockedRequest } from '../utils/request/MockedRequest' - -export type DefaultContext = { - status: typeof status - set: typeof set - delay: typeof delay - fetch: typeof fetch -} - -export const defaultContext: DefaultContext = { - status, - set, - delay, - fetch, -} - -export type DefaultRequestMultipartBody = Record< - string, - string | File | (string | File)[] -> - -export type DefaultBodyType = - | Record - | DefaultRequestMultipartBody - | string - | number - | boolean - | null - | undefined - -export interface RequestHandlerDefaultInfo { - header: string -} - -export interface RequestHandlerInternalInfo { - callFrame?: string -} - -type ContextMap = Record any> - -export type ResponseResolverReturnType = - | ReturnType - | undefined - | void - -export type MaybeAsyncResponseResolverReturnType = MaybePromise< - ResponseResolverReturnType -> - -export type AsyncResponseResolverReturnType = - | MaybeAsyncResponseResolverReturnType - | Generator< - MaybeAsyncResponseResolverReturnType, - MaybeAsyncResponseResolverReturnType, - MaybeAsyncResponseResolverReturnType - > - -export type ResponseResolver< - RequestType = MockedRequest, - ContextType = typeof defaultContext, - BodyType extends DefaultBodyType = any, -> = ( - req: RequestType, - res: ResponseComposition, - context: ContextType, -) => AsyncResponseResolverReturnType> - -export interface RequestHandlerOptions { - info: HandlerInfo - resolver: ResponseResolver - ctx?: ContextMap -} - -export interface RequestHandlerExecutionResult { - handler: RequestHandler - parsedResult: any - request: PublicRequestType - response?: MockedResponse -} - -export abstract class RequestHandler< - HandlerInfo extends RequestHandlerDefaultInfo = RequestHandlerDefaultInfo, - Request extends MockedRequest = MockedRequest, - ParsedResult = any, - PublicRequest extends MockedRequest = Request, -> { - public info: HandlerInfo & RequestHandlerInternalInfo - public shouldSkip: boolean - - private ctx: ContextMap - private resolverGenerator?: Generator< - MaybeAsyncResponseResolverReturnType, - MaybeAsyncResponseResolverReturnType, - MaybeAsyncResponseResolverReturnType - > - private resolverGeneratorResult?: MaybeAsyncResponseResolverReturnType - - protected resolver: ResponseResolver - - constructor(options: RequestHandlerOptions) { - this.shouldSkip = false - this.ctx = options.ctx || defaultContext - this.resolver = options.resolver - - const callFrame = getCallFrame(new Error()) - - this.info = { - ...options.info, - callFrame, - } - } - - /** - * Determine if the captured request should be mocked. - */ - abstract predicate( - request: MockedRequest, - parsedResult: ParsedResult, - resolutionContext?: ResponseResolutionContext, - ): boolean - - /** - * Print out the successfully handled request. - */ - abstract log( - request: Request, - response: SerializedResponse, - parsedResult: ParsedResult, - ): void - - /** - * Parse the captured request to extract additional information from it. - * Parsed result is then exposed to other methods of this request handler. - */ - parse( - _request: MockedRequest, - _resolutionContext?: ResponseResolutionContext, - ): ParsedResult { - return null as any - } - - /** - * Test if this handler matches the given request. - */ - public test( - request: MockedRequest, - resolutionContext?: ResponseResolutionContext, - ): boolean { - return this.predicate( - request, - this.parse(request, resolutionContext), - resolutionContext, - ) - } - - /** - * Derive the publicly exposed request (`req`) instance of the response resolver - * from the captured request and its parsed result. - */ - protected getPublicRequest( - request: MockedRequest, - _parsedResult: ParsedResult, - ) { - return request as PublicRequest - } - - public markAsSkipped(shouldSkip = true) { - this.shouldSkip = shouldSkip - } - - /** - * Execute this request handler and produce a mocked response - * using the given resolver function. - */ - public async run( - request: MockedRequest, - resolutionContext?: ResponseResolutionContext, - ): Promise | null> { - if (this.shouldSkip) { - return null - } - - const parsedResult = this.parse(request, resolutionContext) - const shouldIntercept = this.predicate( - request, - parsedResult, - resolutionContext, - ) - - if (!shouldIntercept) { - return null - } - - const publicRequest = this.getPublicRequest(request, parsedResult) - - // Create a response extraction wrapper around the resolver - // since it can be both an async function and a generator. - const executeResolver = this.wrapResolver(this.resolver) - const mockedResponse = await executeResolver( - publicRequest, - response, - this.ctx, - ) - - return this.createExecutionResult( - parsedResult, - publicRequest, - mockedResponse, - ) - } - - private wrapResolver( - resolver: ResponseResolver, - ): ResponseResolver, any> { - return async (req, res, ctx) => { - const result = this.resolverGenerator || (await resolver(req, res, ctx)) - - if (isIterable>(result)) { - const { value, done } = result[Symbol.iterator]().next() - const nextResponse = await value - - // If the generator is done and there is no next value, - // return the previous generator's value. - if (!nextResponse && done) { - return this.resolverGeneratorResult - } - - if (!this.resolverGenerator) { - this.resolverGenerator = result - } - - this.resolverGeneratorResult = nextResponse - return nextResponse - } - - return result - } - } - - private createExecutionResult( - parsedResult: ParsedResult, - request: PublicRequest, - response: any, - ): RequestHandlerExecutionResult { - return { - handler: this, - parsedResult: parsedResult || null, - request, - response: response || null, - } - } -} - -/** - * Bypass this intercepted request. - * This will make a call to the actual endpoint requested. - */ -export function passthrough(): MockedResponse { - // Constructing a dummy "101 Continue" mocked response - // to keep the return type of the resolver consistent. - return { - status: 101, - statusText: 'Continue', - headers: new Headers(), - body: null, - // Setting "passthrough" to true will signal the response pipeline - // to perform this intercepted request as-is. - passthrough: true, - once: false, - } -} diff --git a/src/handlers/RestHandler.test.ts b/src/handlers/RestHandler.test.ts deleted file mode 100644 index 04bb5bc50..000000000 --- a/src/handlers/RestHandler.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { RestHandler, RestRequest, RestContext } from './RestHandler' -import { response } from '../response' -import { context, MockedRequest } from '..' -import { ResponseResolver } from './RequestHandler' - -const resolver: ResponseResolver< - RestRequest<{ userId: string }>, - RestContext -> = (req, res, ctx) => { - return res(ctx.json({ userId: req.params.userId })) -} - -const generatorResolver: ResponseResolver< - RestRequest<'pending' | 'complete'>, - RestContext -> = function* (req, res, ctx) { - let count = 0 - while (count < 5) { - count += 1 - yield res(ctx.body('pending')) - } - return res(ctx.body('complete')) -} - -describe('info', () => { - test('exposes request handler information', () => { - const handler = new RestHandler('GET', '/user/:userId', resolver) - expect(handler.info.header).toEqual('GET /user/:userId') - expect(handler.info.method).toEqual('GET') - expect(handler.info.path).toEqual('/user/:userId') - }) -}) - -describe('parse', () => { - test('parses a URL given a matching request', () => { - const handler = new RestHandler('GET', '/user/:userId', resolver) - const request = new MockedRequest(new URL('/user/abc-123', location.href)) - - expect(handler.parse(request)).toEqual({ - matches: true, - params: { - userId: 'abc-123', - }, - }) - }) - - test('parses a URL and ignores the request method', () => { - const handler = new RestHandler('GET', '/user/:userId', resolver) - const request = new MockedRequest(new URL('/user/def-456', location.href), { - method: 'POST', - }) - - expect(handler.parse(request)).toEqual({ - matches: true, - params: { - userId: 'def-456', - }, - }) - }) - - test('returns negative match result given a non-matching request', () => { - const handler = new RestHandler('GET', '/user/:userId', resolver) - const request = new MockedRequest(new URL('/login', location.href)) - - expect(handler.parse(request)).toEqual({ - matches: false, - params: {}, - }) - }) -}) - -describe('predicate', () => { - test('returns true given a matching request', () => { - const handler = new RestHandler('POST', '/login', resolver) - const request = new MockedRequest(new URL('/login', location.href), { - method: 'POST', - }) - - expect(handler.predicate(request, handler.parse(request))).toBe(true) - }) - - test('respects RegExp as the request method', () => { - const handler = new RestHandler(/.+/, '/login', resolver) - const requests = [ - new MockedRequest(new URL('/login', location.href)), - new MockedRequest(new URL('/login', location.href), { method: 'POST' }), - new MockedRequest(new URL('/login', location.href), { method: 'DELETE' }), - ] - - for (const request of requests) { - expect(handler.predicate(request, handler.parse(request))).toBe(true) - } - }) - - test('returns false given a non-matching request', () => { - const handler = new RestHandler('POST', '/login', resolver) - const request = new MockedRequest(new URL('/user/abc-123', location.href)) - - expect(handler.predicate(request, handler.parse(request))).toBe(false) - }) -}) - -describe('test', () => { - test('returns true given a matching request', () => { - const handler = new RestHandler('GET', '/user/:userId', resolver) - const firstTest = handler.test( - new MockedRequest(new URL('/user/abc-123', location.href)), - ) - const secondTest = handler.test( - new MockedRequest(new URL('/user/def-456', location.href)), - ) - - expect(firstTest).toBe(true) - expect(secondTest).toBe(true) - }) - - test('returns false given a non-matching request', () => { - const handler = new RestHandler('GET', '/user/:userId', resolver) - const firstTest = handler.test( - new MockedRequest(new URL('/login', location.href)), - ) - const secondTest = handler.test( - new MockedRequest(new URL('/user/', location.href)), - ) - const thirdTest = handler.test( - new MockedRequest(new URL('/user/abc-123/extra', location.href)), - ) - - expect(firstTest).toBe(false) - expect(secondTest).toBe(false) - expect(thirdTest).toBe(false) - }) -}) - -describe('run', () => { - test('returns a mocked response given a matching request', async () => { - const handler = new RestHandler('GET', '/user/:userId', resolver) - const request = new MockedRequest(new URL('/user/abc-123', location.href)) - const result = await handler.run(request) - - expect(result).toEqual({ - handler, - request: { - ...request, - params: { - userId: 'abc-123', - }, - }, - parsedResult: { - matches: true, - params: { - userId: 'abc-123', - }, - }, - response: await response(context.json({ userId: 'abc-123' })), - }) - }) - - test('returns null given a non-matching request', async () => { - const handler = new RestHandler('POST', '/login', resolver) - const result = await handler.run( - new MockedRequest(new URL('/users', location.href)), - ) - - expect(result).toBeNull() - }) - - test('returns an empty object as "req.params" given request with no URL parameters', async () => { - const handler = new RestHandler('GET', '/users', resolver) - const result = await handler.run( - new MockedRequest(new URL('/users', location.href)), - ) - - expect(result?.request.params).toEqual({}) - }) -}) - -describe('run with generator', () => { - test('Resolver runs until generator completes', async () => { - const handler = new RestHandler('GET', '/users', generatorResolver) - const run = async () => { - const result = await handler.run( - new MockedRequest(new URL('/users', location.href)), - ) - return result?.response?.body - } - - expect(await run()).toBe('pending') - expect(await run()).toBe('pending') - expect(await run()).toBe('pending') - expect(await run()).toBe('pending') - expect(await run()).toBe('pending') - expect(await run()).toBe('complete') - expect(await run()).toBe('complete') - expect(handler.shouldSkip).toBe(false) - }) -}) diff --git a/src/handlers/RestHandler.ts b/src/handlers/RestHandler.ts deleted file mode 100644 index 955e26039..000000000 --- a/src/handlers/RestHandler.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { body, cookie, json, text, xml } from '../context' -import type { SerializedResponse } from '../setupWorker/glossary' -import { ResponseResolutionContext } from '../utils/getResponse' -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 { prepareRequest } from '../utils/logging/prepareRequest' -import { prepareResponse } from '../utils/logging/prepareResponse' -import { - Match, - matchRequestUrl, - Path, - PathParams, -} from '../utils/matching/matchRequestUrl' -import { getPublicUrlFromRequest } from '../utils/request/getPublicUrlFromRequest' -import { MockedRequest } from '../utils/request/MockedRequest' -import { cleanUrl, getSearchParams } from '../utils/url/cleanUrl' -import { - DefaultBodyType, - defaultContext, - DefaultContext, - RequestHandler, - RequestHandlerDefaultInfo, - ResponseResolver, -} from './RequestHandler' - -type RestHandlerMethod = string | RegExp - -export interface RestHandlerInfo extends RequestHandlerDefaultInfo { - method: RestHandlerMethod - path: Path -} - -export enum RESTMethods { - HEAD = 'HEAD', - GET = 'GET', - POST = 'POST', - PUT = 'PUT', - PATCH = 'PATCH', - OPTIONS = 'OPTIONS', - DELETE = 'DELETE', -} - -// Declaring a context interface infers -// JSDoc description of the referenced utils. -export type RestContext = DefaultContext & { - cookie: typeof cookie - text: typeof text - body: typeof body - json: typeof json - xml: typeof xml -} - -export const restContext: RestContext = { - ...defaultContext, - cookie, - body, - text, - json, - xml, -} - -export type RequestQuery = { - [queryName: string]: string -} - -export type ParsedRestRequest = Match - -export class RestRequest< - RequestBody extends DefaultBodyType = DefaultBodyType, - RequestParams extends PathParams = PathParams, -> extends MockedRequest { - constructor( - request: MockedRequest, - public readonly params: RequestParams, - ) { - super(request.url, { - ...request, - /** - * @deprecated https://github.com/mswjs/msw/issues/1318 - * @note Use internal request body buffer as the body init - * because "request.body" is a getter that will trigger - * request body parsing at this step. - */ - body: request['_body'], - }) - this.id = request.id - } -} - -/** - * Request handler for REST API requests. - * Provides request matching based on method and URL. - */ -export class RestHandler< - RequestType extends MockedRequest = MockedRequest, -> extends RequestHandler< - RestHandlerInfo, - RequestType, - ParsedRestRequest, - RestRequest< - RequestType extends MockedRequest - ? RequestBodyType - : any, - PathParams - > -> { - constructor( - method: RestHandlerMethod, - path: Path, - resolver: ResponseResolver, - ) { - super({ - info: { - header: `${method} ${path}`, - path, - method, - }, - ctx: restContext, - resolver, - }) - - this.checkRedundantQueryParameters() - } - - private checkRedundantQueryParameters() { - const { method, path } = this.info - - if (path instanceof RegExp) { - return - } - - const url = cleanUrl(path) - - // Bypass request handler URLs that have no redundant characters. - if (url === path) { - return - } - - const searchParams = getSearchParams(path) - const queryParams: string[] = [] - - searchParams.forEach((_, paramName) => { - queryParams.push(paramName) - }) - - devUtils.warn( - `Found a redundant usage of query parameters in the request handler URL for "${method} ${path}". Please match against a path instead and access query parameters in the response resolver function using "req.url.searchParams".`, - ) - } - - parse(request: RequestType, resolutionContext?: ResponseResolutionContext) { - return matchRequestUrl( - request.url, - this.info.path, - resolutionContext?.baseUrl, - ) - } - - protected getPublicRequest( - request: RequestType, - parsedResult: ParsedRestRequest, - ): RestRequest { - return new RestRequest(request, parsedResult.params || {}) - } - - predicate(request: RequestType, parsedResult: ParsedRestRequest) { - const matchesMethod = - this.info.method instanceof RegExp - ? this.info.method.test(request.method) - : isStringEqual(this.info.method, request.method) - - return matchesMethod && parsedResult.matches - } - - log(request: RequestType, response: SerializedResponse) { - const publicUrl = getPublicUrlFromRequest(request) - const loggedRequest = prepareRequest(request) - const loggedResponse = prepareResponse(response) - const statusColor = getStatusCodeColor(response.status) - - console.groupCollapsed( - devUtils.formatMessage('%s %s %s (%c%s%c)'), - getTimestamp(), - request.method, - publicUrl, - `color:${statusColor}`, - `${response.status} ${response.statusText}`, - 'color:inherit', - ) - console.log('Request', loggedRequest) - console.log('Handler:', this) - console.log('Response', loggedResponse) - console.groupEnd() - } -} diff --git a/src/iife/index.ts b/src/iife/index.ts new file mode 100644 index 000000000..189ebc2ec --- /dev/null +++ b/src/iife/index.ts @@ -0,0 +1,2 @@ +export * from '~/core' +export * from '../browser' diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 3bb24a9d3..000000000 --- a/src/index.ts +++ /dev/null @@ -1,75 +0,0 @@ -import * as context from './context' -import { checkGlobals } from './utils/internal/checkGlobals' -export { context } - -export { setupWorker } from './setupWorker/setupWorker' - -export { SetupApi } from './SetupApi' - -export { - response, - defaultResponse, - createResponseComposition, -} from './response' - -/* Request handlers */ -export { RequestHandler, defaultContext } from './handlers/RequestHandler' -export { rest } from './rest' -export { RestHandler, RESTMethods, restContext } from './handlers/RestHandler' -export { graphql } from './graphql' -export { GraphQLHandler, graphqlContext } from './handlers/GraphQLHandler' - -/* Utils */ -export { matchRequestUrl } from './utils/matching/matchRequestUrl' -export { compose } from './utils/internal/compose' -export * from './utils/handleRequest' -export { cleanUrl } from './utils/url/cleanUrl' - -/** - * Type definitions. - */ -export type { SetupWorker, StartOptions } from './setupWorker/glossary' -export { SetupWorkerApi } from './setupWorker/setupWorker' -export type { SharedOptions } from './sharedOptions' - -export * from './utils/request/MockedRequest' -export type { - ResponseResolver, - ResponseResolverReturnType, - AsyncResponseResolverReturnType, - DefaultBodyType, - DefaultRequestMultipartBody, -} from './handlers/RequestHandler' - -export type { - MockedResponse, - ResponseTransformer, - ResponseComposition, - ResponseCompositionOptions, - ResponseFunction, -} from './response' - -export type { - RestRequest, - RestContext, - RequestQuery, - ParsedRestRequest, -} from './handlers/RestHandler' - -export type { - GraphQLContext, - GraphQLVariables, - GraphQLRequest, - GraphQLRequestBody, - GraphQLJsonRequestBody, -} from './handlers/GraphQLHandler' - -export type { Path, PathParams, Match } from './utils/matching/matchRequestUrl' -export type { DelayMode } from './context/delay' -export { ParsedGraphQLRequest } from './utils/internal/parseGraphQLRequest' - -// Validate environmental globals before executing any code. -// This ensures that the library gives user-friendly errors -// when ran in the environments that require additional polyfills -// from the end user. -checkGlobals() diff --git a/src/mockServiceWorker.js b/src/mockServiceWorker.js index dabd1abe0..faa67b6c8 100644 --- a/src/mockServiceWorker.js +++ b/src/mockServiceWorker.js @@ -9,6 +9,7 @@ */ const INTEGRITY_CHECKSUM = '' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() self.addEventListener('install', function () { @@ -86,12 +87,6 @@ self.addEventListener('message', async function (event) { self.addEventListener('fetch', function (event) { const { request } = event - const accept = request.headers.get('accept') || '' - - // Bypass server-sent events. - if (accept.includes('text/event-stream')) { - return - } // Bypass navigation requests. if (request.mode === 'navigate') { @@ -112,29 +107,8 @@ self.addEventListener('fetch', function (event) { } // Generate unique request ID. - const requestId = Math.random().toString(16).slice(2) - - event.respondWith( - handleRequest(event, requestId).catch((error) => { - if (error.name === 'NetworkError') { - console.warn( - '[MSW] Successfully emulated a network error for the "%s %s" request.', - request.method, - request.url, - ) - return - } - - // At this point, any exception indicates an issue with the original request/response. - console.error( - `\ -[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, - request.method, - request.url, - `${error.name}: ${error.message}`, - ) - }), - ) + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId)) }) async function handleRequest(event, requestId) { @@ -146,21 +120,29 @@ async function handleRequest(event, requestId) { // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { ;(async function () { - const clonedResponse = response.clone() - sendToClient(client, { - type: 'RESPONSE', - payload: { - requestId, - type: clonedResponse.type, - ok: clonedResponse.ok, - status: clonedResponse.status, - statusText: clonedResponse.statusText, - body: - clonedResponse.body === null ? null : await clonedResponse.text(), - headers: Object.fromEntries(clonedResponse.headers.entries()), - redirected: clonedResponse.redirected, + const responseClone = response.clone() + // When performing original requests, response body will + // always be a ReadableStream, even for 204 responses. + // But when creating a new Response instance on the client, + // the body for a 204 response must be null. + const responseBody = response.status === 204 ? null : responseClone.body + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseBody, + headers: Object.fromEntries(responseClone.headers.entries()), + }, }, - }) + [responseBody], + ) })() } @@ -196,20 +178,20 @@ async function resolveMainClient(event) { async function getResponse(event, client, requestId) { const { request } = event - const clonedRequest = request.clone() + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone() function passthrough() { - // Clone the request because it might've been already used - // (i.e. its body has been read and sent to the client). - const headers = Object.fromEntries(clonedRequest.headers.entries()) + const headers = Object.fromEntries(requestClone.headers.entries()) - // Remove MSW-specific request headers so the bypassed requests - // comply with the server's CORS preflight check. - // Operate with the headers as an object because request "Headers" - // are immutable. - delete headers['x-msw-bypass'] + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention'] - return fetch(clonedRequest, { headers }) + return fetch(requestClone, { headers }) } // Bypass mocking when the client is not active. @@ -227,31 +209,36 @@ async function getResponse(event, client, requestId) { // Bypass requests with the explicit bypass header. // Such requests can be issued by "ctx.fetch()". - if (request.headers.get('x-msw-bypass') === 'true') { + const mswIntention = request.headers.get('x-msw-intention') + if (['bypass', 'passthrough'].includes(mswIntention)) { return passthrough() } // Notify the client that a request has been intercepted. - const clientMessage = await sendToClient(client, { - type: 'REQUEST', - payload: { - id: requestId, - url: request.url, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - mode: request.mode, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: await request.text(), - bodyUsed: request.bodyUsed, - keepalive: request.keepalive, + const requestBuffer = await request.arrayBuffer() + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, }, - }) + [requestBuffer], + ) switch (clientMessage.type) { case 'MOCK_RESPONSE': { @@ -261,21 +248,12 @@ async function getResponse(event, client, requestId) { case 'MOCK_NOT_FOUND': { return passthrough() } - - case 'NETWORK_ERROR': { - const { name, message } = clientMessage.data - const networkError = new Error(message) - networkError.name = name - - // Rejecting a "respondWith" promise emulates a network error. - throw networkError - } } return passthrough() } -function sendToClient(client, message) { +function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { const channel = new MessageChannel() @@ -287,17 +265,28 @@ function sendToClient(client, message) { resolve(event.data) } - client.postMessage(message, [channel.port2]) + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)), + ) }) } -function sleep(timeMs) { - return new Promise((resolve) => { - setTimeout(resolve, timeMs) +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, }) -} -async function respondWithMock(response) { - await sleep(response.delay) - return new Response(response.body, response) + return mockedResponse } diff --git a/src/native/index.ts b/src/native/index.ts index c0acc4d32..245c1fb93 100644 --- a/src/native/index.ts +++ b/src/native/index.ts @@ -1,11 +1,12 @@ -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/lib/interceptors/XMLHttpRequest' -import { RequestHandler } from '../handlers/RequestHandler' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { RequestHandler } from '~/core/handlers/RequestHandler' 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 02cf2932d..a451f521b 100644 --- a/src/node/SetupServerApi.ts +++ b/src/node/SetupServerApi.ts @@ -1,36 +1,27 @@ -import chalk from 'chalk' +import { setMaxListeners, defaultMaxListeners } from 'node:events' import { invariant } from 'outvariant' import { BatchInterceptor, HttpRequestEventMap, Interceptor, InterceptorReadyState, - IsomorphicResponse, - MockedResponse as MockedInterceptedResponse, } from '@mswjs/interceptors' -import { SetupApi } from '../SetupApi' -import { RequestHandler } from '../handlers/RequestHandler' -import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions' -import { RequiredDeep } from '../typeUtils' -import { mergeRight } from '../utils/internal/mergeRight' -import { MockedRequest } from '../utils/request/MockedRequest' -import { handleRequest } from '../utils/handleRequest' -import { devUtils } from '../utils/internal/devUtils' +import { SetupApi } from '~/core/SetupApi' +import { RequestHandler } from '~/core/handlers/RequestHandler' +import { LifeCycleEventsMap, SharedOptions } from '~/core/sharedOptions' +import { RequiredDeep } from '~/core/typeUtils' +import { mergeRight } from '~/core/utils/internal/mergeRight' +import { handleRequest } from '~/core/utils/handleRequest' +import { devUtils } from '~/core/utils/internal/devUtils' import { SetupServer } from './glossary' - -/** - * @see https://github.com/mswjs/msw/pull/1399 - */ -const { bold } = chalk - -export type ServerLifecycleEventsMap = LifeCycleEventsMap +import { isNodeException } from './utils/isNodeException' const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { onUnhandledRequest: 'warn', } export class SetupServerApi - extends SetupApi + extends SetupApi implements SetupServer { protected readonly interceptor: BatchInterceptor< @@ -60,59 +51,70 @@ export class SetupServerApi * Subscribe to all requests that are using the interceptor object */ private init(): void { - this.interceptor.on('request', async (request) => { - const mockedRequest = new MockedRequest(request.url, { - ...request, - body: await request.arrayBuffer(), - }) + 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< - MockedInterceptedResponse & { delay?: number } - >( - mockedRequest, + const response = await handleRequest( + request, + requestId, this.currentHandlers, this.resolvedOptions, this.emitter, - { - transformResponse(response) { - return { - status: response.status, - statusText: response.statusText, - headers: response.headers.all(), - body: response.body, - delay: response.delay, - } - }, - }, ) if (response) { - // Delay Node.js responses in the listener so that - // the response lookup logic is not concerned with responding - // in any way. The same delay is implemented in the worker. - if (response.delay) { - await new Promise((resolve) => { - setTimeout(resolve, response.delay) - }) - } - request.respondWith(response) } return }) - this.interceptor.on('response', (request, response) => { - if (!request.id) { - return - } - - if (response.headers.get('x-powered-by') === 'msw') { - this.emitter.emit('response:mocked', response, request.id) - } else { - this.emitter.emit('response:bypass', response, request.id) - } - }) + this.interceptor.on( + 'response', + ({ response, isMockedResponse, request, requestId }) => { + this.emitter.emit( + isMockedResponse ? 'response:mocked' : 'response:bypass', + { + response, + request, + requestId, + }, + ) + }, + ) } public listen(options: Partial = {}): void { @@ -124,6 +126,10 @@ export class SetupServerApi // Apply the interceptor when starting the server. this.interceptor.apply() + this.subscriptions.push(() => { + this.interceptor.dispose() + }) + // Assert that the interceptor has been applied successfully. // Also guards us from forgetting to call "interceptor.apply()" // as a part of the "listen" method. @@ -138,25 +144,7 @@ export class SetupServerApi ) } - public printHandlers(): void { - const handlers = this.listHandlers() - - handlers.forEach((handler) => { - const { header, callFrame } = handler.info - - const pragma = handler.info.hasOwnProperty('operationType') - ? '[graphql]' - : '[rest]' - - console.log(`\ -${bold(`${pragma} ${header}`)} - Declaration: ${callFrame} -`) - }) - } - public close(): void { - super.dispose() - this.interceptor.dispose() + this.dispose() } } diff --git a/src/node/glossary.ts b/src/node/glossary.ts index c8ed0aa85..0edda3ce8 100644 --- a/src/node/glossary.ts +++ b/src/node/glossary.ts @@ -1,68 +1,62 @@ import type { PartialDeep } from 'type-fest' -import type { IsomorphicResponse } from '@mswjs/interceptors' import { - DefaultBodyType, RequestHandler, RequestHandlerDefaultInfo, -} from '../handlers/RequestHandler' +} from '~/core/handlers/RequestHandler' import { LifeCycleEventEmitter, LifeCycleEventsMap, SharedOptions, -} from '../sharedOptions' -import { MockedRequest } from '../utils/request/MockedRequest' - -export type ServerLifecycleEventsMap = LifeCycleEventsMap +} from '~/core/sharedOptions' 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: RequestHandler[]): void + 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: RequestHandler[]): void + 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< - RequestHandler< - RequestHandlerDefaultInfo, - MockedRequest, - any, - MockedRequest - > - > + listHandlers(): ReadonlyArray> /** - * Lists all active request handlers. - * @see {@link https://mswjs.io/docs/api/setup-server/print-handlers `server.print-handlers()`} + * 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} */ - printHandlers(): void - - events: LifeCycleEventEmitter + events: LifeCycleEventEmitter } diff --git a/src/node/index.ts b/src/node/index.ts index 11a0dd223..d9b2ea46c 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -1,4 +1,3 @@ -export { ServerLifecycleEventsMap } from './SetupServerApi' -export { setupServer } from './setupServer' export type { SetupServer } from './glossary' export { SetupServerApi } from './SetupServerApi' +export { setupServer } from './setupServer' diff --git a/src/node/setupServer.ts b/src/node/setupServer.ts index c71e40370..06b2546af 100644 --- a/src/node/setupServer.ts +++ b/src/node/setupServer.ts @@ -1,14 +1,15 @@ -import { ClientRequestInterceptor } from '@mswjs/interceptors/lib/interceptors/ClientRequest/index.js' -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/lib/interceptors/XMLHttpRequest/index.js' -import { FetchInterceptor } from '@mswjs/interceptors/lib/interceptors/fetch/index.js' -import { RequestHandler } from '../handlers/RequestHandler' +import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' +import { FetchInterceptor } from '@mswjs/interceptors/fetch' +import { RequestHandler } from '~/core/handlers/RequestHandler' import { SetupServer } from './glossary' 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/src/response.ts b/src/response.ts deleted file mode 100644 index 674ffbd42..000000000 --- a/src/response.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Headers } from 'headers-polyfill' -import { DefaultBodyType } from './handlers/RequestHandler' -import { compose } from './utils/internal/compose' -import { NetworkError } from './utils/NetworkError' - -export type MaybePromise = ValueType | Promise - -/** - * Internal representation of a mocked response instance. - */ -export interface MockedResponse { - body: BodyType - status: number - statusText: string - headers: Headers - once: boolean - passthrough: boolean - delay?: number -} - -export type ResponseTransformer< - BodyType extends TransformerBodyType = any, - TransformerBodyType extends DefaultBodyType = any, -> = ( - res: MockedResponse, -) => MaybePromise> - -export type ResponseFunction = ( - ...transformers: ResponseTransformer[] -) => MaybePromise> - -export type ResponseComposition = - ResponseFunction & { - /** - * Respond using a given mocked response to the first captured request. - * Does not affect any subsequent captured requests. - */ - once: ResponseFunction - networkError: (message: string) => void - } - -export const defaultResponse: Omit = { - status: 200, - statusText: 'OK', - body: null, - delay: 0, - once: false, - passthrough: false, -} - -export type ResponseCompositionOptions = { - defaultTransformers?: ResponseTransformer[] - mockedResponseOverrides?: Partial -} - -export const defaultResponseTransformers: ResponseTransformer[] = [] - -export function createResponseComposition( - responseOverrides?: Partial>, - defaultTransformers: ResponseTransformer[] = defaultResponseTransformers, -): ResponseFunction { - return async (...transformers) => { - const initialResponse: MockedResponse = Object.assign( - {}, - defaultResponse, - { - headers: new Headers({ - 'x-powered-by': 'msw', - }), - }, - responseOverrides, - ) - - const resolvedTransformers = [ - ...defaultTransformers, - ...transformers, - ].filter(Boolean) - - const resolvedResponse = - resolvedTransformers.length > 0 - ? compose(...resolvedTransformers)(initialResponse) - : initialResponse - - return resolvedResponse - } -} - -export const response = Object.assign(createResponseComposition(), { - once: createResponseComposition({ once: true }), - networkError(message: string) { - throw new NetworkError(message) - }, -}) diff --git a/src/rest.ts b/src/rest.ts deleted file mode 100644 index 47cc23076..000000000 --- a/src/rest.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { DefaultBodyType, ResponseResolver } from './handlers/RequestHandler' -import { - RESTMethods, - RestContext, - RestHandler, - RestRequest, -} from './handlers/RestHandler' -import { Path, PathParams } from './utils/matching/matchRequestUrl' - -function createRestHandler( - method: Method, -) { - return < - RequestBodyType extends DefaultBodyType = DefaultBodyType, - Params extends PathParams = PathParams, - ResponseBody extends DefaultBodyType = DefaultBodyType, - >( - path: Path, - resolver: ResponseResolver< - RestRequest< - Method extends RESTMethods.HEAD | RESTMethods.GET - ? never - : RequestBodyType, - Params - >, - RestContext, - ResponseBody - >, - ) => { - return new RestHandler(method, path, resolver) - } -} - -export const rest = { - all: createRestHandler(/.+/), - head: createRestHandler(RESTMethods.HEAD), - get: createRestHandler(RESTMethods.GET), - post: createRestHandler(RESTMethods.POST), - put: createRestHandler(RESTMethods.PUT), - delete: createRestHandler(RESTMethods.DELETE), - patch: createRestHandler(RESTMethods.PATCH), - options: createRestHandler(RESTMethods.OPTIONS), -} diff --git a/src/setupWorker/start/createFallbackRequestListener.ts b/src/setupWorker/start/createFallbackRequestListener.ts deleted file mode 100644 index db7eb76eb..000000000 --- a/src/setupWorker/start/createFallbackRequestListener.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - Interceptor, - BatchInterceptor, - HttpRequestEventMap, -} from '@mswjs/interceptors' -import { FetchInterceptor } from '@mswjs/interceptors/lib/interceptors/fetch' -import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/lib/interceptors/XMLHttpRequest' -import { - SerializedResponse, - SetupWorkerInternalContext, - StartOptions, -} from '../glossary' -import type { RequiredDeep } from '../../typeUtils' -import { handleRequest } from '../../utils/handleRequest' -import { MockedRequest } from '../../utils/request/MockedRequest' -import { serializeResponse } from '../../utils/logging/serializeResponse' -import { createResponseFromIsomorphicResponse } from '../../utils/request/createResponseFromIsomorphicResponse' - -export function createFallbackRequestListener( - context: SetupWorkerInternalContext, - options: RequiredDeep, -): Interceptor { - const interceptor = new BatchInterceptor({ - name: 'fallback', - interceptors: [new FetchInterceptor(), new XMLHttpRequestInterceptor()], - }) - - interceptor.on('request', async (request) => { - const mockedRequest = new MockedRequest(request.url, { - ...request, - body: await request.arrayBuffer(), - }) - - const response = await handleRequest( - mockedRequest, - context.requestHandlers, - options, - context.emitter, - { - transformResponse(response) { - return { - status: response.status, - statusText: response.statusText, - headers: response.headers.all(), - body: response.body, - delay: response.delay, - } - }, - onMockedResponse(_, { handler, publicRequest, parsedRequest }) { - if (!options.quiet) { - context.emitter.once('response:mocked', async (response) => { - handler.log( - publicRequest, - await serializeResponse(response), - parsedRequest, - ) - }) - } - }, - }, - ) - - if (response) { - request.respondWith(response) - } - }) - - interceptor.on('response', (request, response) => { - if (!request.id) { - return - } - - const browserResponse = createResponseFromIsomorphicResponse(response) - - if (response.headers.get('x-powered-by') === 'msw') { - context.emitter.emit('response:mocked', browserResponse, request.id) - } else { - context.emitter.emit('response:bypass', browserResponse, request.id) - } - }) - - interceptor.apply() - - return interceptor -} diff --git a/src/setupWorker/start/createRequestListener.ts b/src/setupWorker/start/createRequestListener.ts deleted file mode 100644 index 090b13c46..000000000 --- a/src/setupWorker/start/createRequestListener.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { - StartOptions, - SerializedResponse, - SetupWorkerInternalContext, - ServiceWorkerIncomingEventsMap, -} from '../glossary' -import { - ServiceWorkerMessage, - WorkerChannel, -} from './utils/createMessageChannel' -import { NetworkError } from '../../utils/NetworkError' -import { parseWorkerRequest } from '../../utils/request/parseWorkerRequest' -import { handleRequest } from '../../utils/handleRequest' -import { RequiredDeep } from '../../typeUtils' -import { MockedResponse } from '../../response' -import { devUtils } from '../../utils/internal/devUtils' -import { serializeResponse } from '../../utils/logging/serializeResponse' - -export const createRequestListener = ( - context: SetupWorkerInternalContext, - options: RequiredDeep, -) => { - return async ( - event: MessageEvent, - message: ServiceWorkerMessage< - 'REQUEST', - ServiceWorkerIncomingEventsMap['REQUEST'] - >, - ) => { - const messageChannel = new WorkerChannel(event.ports[0]) - const request = parseWorkerRequest(message.payload) - - try { - await handleRequest( - request, - context.requestHandlers, - options, - context.emitter, - { - transformResponse, - onPassthroughResponse() { - messageChannel.postMessage('NOT_FOUND') - }, - async onMockedResponse( - response, - { handler, publicRequest, parsedRequest }, - ) { - if (response.body instanceof ReadableStream) { - throw new Error( - devUtils.formatMessage( - 'Failed to construct a mocked response with a "ReadableStream" body: mocked streams are not supported. Follow https://github.com/mswjs/msw/issues/1336 for more details.', - ), - ) - } - - const responseInstance = new Response(response.body, response) - const responseForLogs = responseInstance.clone() - const responseBodyBuffer = await responseInstance.arrayBuffer() - - // If the mocked response has no body, keep it that way. - // Sending an empty "ArrayBuffer" to the worker will cause - // the worker constructing "new Response(new ArrayBuffer(0))" - // which will throw on responses that must have no body (i.e. 204). - const responseBody = - response.body == null ? null : responseBodyBuffer - - messageChannel.postMessage( - 'MOCK_RESPONSE', - { - ...response, - body: responseBody, - }, - [responseBodyBuffer], - ) - - if (!options.quiet) { - context.emitter.once('response:mocked', async () => { - handler.log( - publicRequest, - await serializeResponse(responseForLogs), - parsedRequest, - ) - }) - } - }, - }, - ) - } catch (error) { - if (error instanceof NetworkError) { - // Treat emulated network error differently, - // as it is an intended exception in a request handler. - messageChannel.postMessage('NETWORK_ERROR', { - name: error.name, - message: error.message, - }) - - return - } - - if (error instanceof Error) { - devUtils.error( - `Uncaught exception in the request handler for "%s %s": - -%s - -This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses`, - request.method, - request.url, - error.stack ?? error, - ) - - // Treat all other exceptions in a request handler as unintended, - // alerting that there is a problem that needs fixing. - messageChannel.postMessage('MOCK_RESPONSE', { - status: 500, - statusText: 'Request Handler Error', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: error.name, - message: error.message, - stack: error.stack, - }), - }) - } - } - } -} - -function transformResponse( - response: MockedResponse, -): SerializedResponse { - return { - status: response.status, - statusText: response.statusText, - headers: response.headers.all(), - body: response.body, - delay: response.delay, - } -} diff --git a/src/setupWorker/start/utils/streamResponse.ts b/src/setupWorker/start/utils/streamResponse.ts deleted file mode 100644 index 8f8b46a5a..000000000 --- a/src/setupWorker/start/utils/streamResponse.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { invariant } from 'outvariant' -import { StrictBroadcastChannel } from '../../../utils/internal/StrictBroadcastChannel' -import { - SerializedResponse, - ServiceWorkerBroadcastChannelMessageMap, -} from '../../glossary' -import { WorkerMessageChannel } from './createMessageChannel' - -export async function streamResponse( - operationChannel: StrictBroadcastChannel, - messageChannel: WorkerMessageChannel, - mockedResponse: SerializedResponse, -): Promise { - const response = new Response(mockedResponse.body, mockedResponse) - - /** - * Delete the ReadableStream response body - * so it doesn't get sent via the message channel. - * @note Otherwise, an error: cannot clone a ReadableStream if - * it hasn't been transformed yet. - */ - delete mockedResponse.body - - // Signal the mock response stream start event on the global - // message channel because the worker expects an event in response - // to the sent "REQUEST" global event. - messageChannel.send({ - type: 'MOCK_RESPONSE_START', - payload: mockedResponse, - }) - - invariant(response.body, 'Failed to stream mocked response with no body') - - // Read the mocked response body as stream - // and pipe it to the worker. - const reader = response.body.getReader() - - while (true) { - const { done, value } = await reader.read() - - if (!done) { - operationChannel.postMessage({ - type: 'MOCK_RESPONSE_CHUNK', - payload: value, - }) - continue - } - - operationChannel.postMessage({ - type: 'MOCK_RESPONSE_END', - }) - operationChannel.close() - reader.releaseLock() - break - } -} diff --git a/src/sharedOptions.ts b/src/sharedOptions.ts deleted file mode 100644 index 2ed9f012c..000000000 --- a/src/sharedOptions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Emitter } from 'strict-event-emitter' -import { MockedRequest } from './utils/request/MockedRequest' -import { UnhandledRequestStrategy } from './utils/request/onUnhandledRequest' - -export interface SharedOptions { - /** - * Specifies how to react to a request that has no corresponding - * request handler. Warns on unhandled requests by default. - * - * @example worker.start({ onUnhandledRequest: 'bypass' }) - * @example worker.start({ onUnhandledRequest: 'warn' }) - * @example server.listen({ onUnhandledRequest: 'error' }) - */ - onUnhandledRequest?: UnhandledRequestStrategy -} - -export interface LifeCycleEventsMap { - 'request:start': [MockedRequest] - 'request:match': [MockedRequest] - 'request:unhandled': [MockedRequest] - 'request:end': [MockedRequest] - 'response:mocked': [response: ResponseType, requestId: string] - 'response:bypass': [response: ResponseType, requestId: string] - unhandledException: [error: Error, request: MockedRequest] - [key: string]: Array -} - -export type LifeCycleEventEmitter< - ResponseType extends Record, -> = Pick, 'on' | 'removeListener' | 'removeAllListeners'> diff --git a/src/utils/NetworkError.ts b/src/utils/NetworkError.ts deleted file mode 100644 index 50d03995e..000000000 --- a/src/utils/NetworkError.ts +++ /dev/null @@ -1,6 +0,0 @@ -export class NetworkError extends Error { - constructor(message: string) { - super(message) - this.name = 'NetworkError' - } -} diff --git a/src/utils/getResponse.ts b/src/utils/getResponse.ts deleted file mode 100644 index e5c0927b5..000000000 --- a/src/utils/getResponse.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { MockedResponse } from '../response' -import { - RequestHandler, - RequestHandlerExecutionResult, -} from '../handlers/RequestHandler' -import { MockedRequest } from './request/MockedRequest' - -export interface ResponseLookupResult { - handler?: RequestHandler - publicRequest?: any - parsedRequest?: any - response?: MockedResponse -} - -export interface ResponseResolutionContext { - baseUrl?: string -} - -/** - * Returns a mocked response for a given request using following request handlers. - */ -export const getResponse = async < - Request extends MockedRequest, - Handler extends RequestHandler[], ->( - request: Request, - handlers: Handler, - resolutionContext?: ResponseResolutionContext, -): Promise => { - const relevantHandlers = handlers.filter((handler) => { - return handler.test(request, resolutionContext) - }) - - if (relevantHandlers.length === 0) { - return { - handler: undefined, - response: undefined, - } - } - - const result = await relevantHandlers.reduce< - Promise | null> - >(async (executionResult, handler) => { - const previousResults = await executionResult - - if (!!previousResults?.response) { - return executionResult - } - - const result = await handler.run(request, resolutionContext) - - if (result === null || result.handler.shouldSkip) { - return null - } - - if (!result.response) { - return { - request: result.request, - handler: result.handler, - response: undefined, - parsedResult: result.parsedResult, - } - } - - if (result.response.once) { - handler.markAsSkipped(true) - } - - return result - }, Promise.resolve(null)) - - // Although reducing a list of relevant request handlers, it's possible - // that in the end there will be no handler associted with the request - // (i.e. if relevant handlers are fall-through). - if (!result) { - return { - handler: undefined, - response: undefined, - } - } - - return { - handler: result.handler, - publicRequest: result.request, - parsedRequest: result.parsedResult, - response: result.response, - } -} diff --git a/src/utils/handleRequest.test.ts b/src/utils/handleRequest.test.ts deleted file mode 100644 index 168c0e8ce..000000000 --- a/src/utils/handleRequest.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { Headers } from 'headers-polyfill' -import { Emitter } from 'strict-event-emitter' -import { ServerLifecycleEventsMap } from '../node/glossary' -import { SharedOptions } from '../sharedOptions' -import { RequestHandler } from '../handlers/RequestHandler' -import { rest } from '../rest' -import { handleRequest, HandleRequestOptions } from './handleRequest' -import { response } from '../response' -import { context, MockedRequest } from '..' -import { RequiredDeep } from '../typeUtils' - -const options: RequiredDeep = { - onUnhandledRequest: jest.fn(), -} -const callbacks: Partial, any>> = { - onPassthroughResponse: jest.fn(), - onMockedResponse: jest.fn(), -} - -function setup() { - const emitter = new Emitter() - const listener = jest.fn() - - const createMockListener = (name: string) => { - return (...args: any) => { - listener(name, ...args) - } - } - - emitter.on('request:start', createMockListener('request:start')) - emitter.on('request:match', createMockListener('request:match')) - emitter.on('request:unhandled', createMockListener('request:unhandled')) - emitter.on('request:end', createMockListener('request:end')) - emitter.on('response:mocked', createMockListener('response:mocked')) - emitter.on('response:bypass', createMockListener('response:bypass')) - - const events = listener.mock.calls - return { emitter, events } -} - -beforeEach(() => { - jest.spyOn(global.console, 'warn').mockImplementation() -}) - -afterEach(() => { - jest.resetAllMocks() -}) - -test('returns undefined for a request with the "x-msw-bypass" header equal to "true"', async () => { - const { emitter, events } = setup() - - const request = new MockedRequest(new URL('http://localhost/user'), { - headers: new Headers({ - 'x-msw-bypass': 'true', - }), - }) - const handlers: Array = [] - - const result = await handleRequest( - request, - handlers, - options, - emitter, - callbacks, - ) - - expect(result).toBeUndefined() - expect(events).toEqual([ - ['request:start', request], - ['request:end', request], - ]) - expect(options.onUnhandledRequest).not.toHaveBeenCalled() - expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) - expect(callbacks.onMockedResponse).not.toHaveBeenCalled() -}) - -test('does not bypass a request with "x-msw-bypass" header set to arbitrary value', async () => { - const { emitter } = setup() - - const request = new MockedRequest(new URL('http://localhost/user'), { - headers: new Headers({ - 'x-msw-bypass': 'anything', - }), - }) - const handlers: Array = [ - rest.get('/user', (req, res, ctx) => { - return res(ctx.text('hello world')) - }), - ] - - const result = await handleRequest( - request, - handlers, - options, - emitter, - callbacks, - ) - - expect(result).not.toBeUndefined() - expect(options.onUnhandledRequest).not.toHaveBeenCalled() - expect(callbacks.onMockedResponse).toHaveBeenCalledTimes(1) -}) - -test('reports request as unhandled when it has no matching request handlers', async () => { - const { emitter, events } = setup() - - const request = new MockedRequest(new URL('http://localhost/user')) - const handlers: Array = [] - - const result = await handleRequest( - request, - handlers, - options, - emitter, - callbacks, - ) - - expect(result).toBeUndefined() - expect(events).toEqual([ - ['request:start', request], - ['request:unhandled', request], - ['request:end', request], - ]) - expect(options.onUnhandledRequest).toHaveBeenNthCalledWith(1, request, { - warning: expect.any(Function), - error: expect.any(Function), - }) - expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) - expect(callbacks.onMockedResponse).not.toHaveBeenCalled() -}) - -test('returns undefined and warns on a request handler that returns no response', async () => { - const { emitter, events } = setup() - - const request = new MockedRequest(new URL('http://localhost/user')) - const handlers: Array = [ - rest.get('/user', () => { - // Intentionally blank response resolver. - return - }), - ] - - const result = await handleRequest( - request, - handlers, - options, - emitter, - callbacks, - ) - - expect(result).toBeUndefined() - expect(events).toEqual([ - ['request:start', request], - ['request:end', request], - ]) - expect(options.onUnhandledRequest).not.toHaveBeenCalled() - expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) - expect(callbacks.onMockedResponse).not.toHaveBeenCalled() - - expect(console.warn).toHaveBeenCalledTimes(1) - const warning = (console.warn as unknown as jest.SpyInstance).mock.calls[0][0] - - expect(warning).toContain( - '[MSW] Expected response resolver to return a mocked response Object, but got undefined. The original response is going to be used instead.', - ) - expect(warning).toContain('GET /user') - expect(warning).toMatch(/\d+:\d+/) -}) - -test('returns the mocked response for a request with a matching request handler', async () => { - const { emitter, events } = setup() - - const request = new MockedRequest(new URL('http://localhost/user')) - const mockedResponse = await response(context.json({ firstName: 'John' })) - const handlers: Array = [ - rest.get('/user', () => { - return mockedResponse - }), - ] - const lookupResult = { - handler: handlers[0], - response: mockedResponse, - publicRequest: { ...request, params: {} }, - parsedRequest: { matches: true, params: {} }, - } - - const result = await handleRequest( - request, - handlers, - options, - emitter, - callbacks, - ) - - expect(result).toEqual(mockedResponse) - expect(events).toEqual([ - ['request:start', request], - ['request:match', request], - ['request:end', request], - ]) - expect(callbacks.onPassthroughResponse).not.toHaveBeenCalled() - expect(callbacks.onMockedResponse).toHaveBeenNthCalledWith( - 1, - mockedResponse, - lookupResult, - ) -}) - -test('returns a transformed response if the "transformResponse" option is provided', async () => { - const { emitter, events } = setup() - - const request = new MockedRequest(new URL('http://localhost/user')) - const mockedResponse = await response(context.json({ firstName: 'John' })) - const handlers: Array = [ - rest.get('/user', () => { - return mockedResponse - }), - ] - const transformResponse = jest.fn().mockImplementation((response) => ({ - body: response.body, - })) - const finalResponse = transformResponse(mockedResponse) - const lookupResult = { - handler: handlers[0], - response: mockedResponse, - publicRequest: { ...request, params: {} }, - parsedRequest: { matches: true, params: {} }, - } - - const result = await handleRequest(request, handlers, options, emitter, { - ...callbacks, - transformResponse, - }) - - expect(result).toEqual(finalResponse) - expect(events).toEqual([ - ['request:start', request], - ['request:match', request], - ['request:end', request], - ]) - expect(callbacks.onPassthroughResponse).not.toHaveBeenCalled() - expect(transformResponse).toHaveBeenNthCalledWith(1, mockedResponse) - expect(callbacks.onMockedResponse).toHaveBeenNthCalledWith( - 1, - finalResponse, - lookupResult, - ) -}) - -it('returns undefined without warning on a passthrough request', async () => { - const { emitter, events } = setup() - - const request = new MockedRequest(new URL('http://localhost/user')) - const handlers: Array = [ - rest.get('/user', (req) => { - return req.passthrough() - }), - ] - - const result = await handleRequest( - request, - handlers, - options, - emitter, - callbacks, - ) - - expect(result).toBeUndefined() - expect(events).toEqual([ - ['request:start', request], - ['request:end', request], - ]) - expect(options.onUnhandledRequest).not.toHaveBeenCalled() - expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request) - expect(callbacks.onMockedResponse).not.toHaveBeenCalled() -}) diff --git a/src/utils/internal/StrictBroadcastChannel.ts b/src/utils/internal/StrictBroadcastChannel.ts deleted file mode 100644 index 15b7237e4..000000000 --- a/src/utils/internal/StrictBroadcastChannel.ts +++ /dev/null @@ -1,27 +0,0 @@ -const ParentClass = - typeof BroadcastChannel == 'undefined' - ? class UnsupportedEnvironment { - constructor() { - throw new Error( - 'Cannot construct BroadcastChannel in a non-browser environment', - ) - } - } - : BroadcastChannel - -export class StrictBroadcastChannel< - MessageMap extends Record, -> extends (ParentClass as unknown as { new (name: string): BroadcastChannel }) { - public postMessage( - message: Parameters[0] extends undefined - ? { - type: MessageType - } - : { - type: MessageType - payload: Parameters[0] - }, - ): void { - return super.postMessage(message) - } -} diff --git a/src/utils/internal/compose.test.ts b/src/utils/internal/compose.test.ts deleted file mode 100644 index 024ad7232..000000000 --- a/src/utils/internal/compose.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { compose } from './compose' - -test('composes given functions from right to left', () => { - const list: number[] = [] - const populateList = compose( - () => list.push(1), - () => list.push(7), - () => list.push(5), - ) - - populateList() - - expect(list).toEqual([5, 7, 1]) -}) - -test('composes a list of async functions from right to left', async () => { - const generateNumber = compose( - async (n: number) => n + 1, - async (n: number) => n * 5, - ) - const number = await generateNumber(5) - - expect(number).toEqual(26) -}) diff --git a/src/utils/internal/compose.ts b/src/utils/internal/compose.ts deleted file mode 100644 index a13ca1bf9..000000000 --- a/src/utils/internal/compose.ts +++ /dev/null @@ -1,46 +0,0 @@ -type ArityOneFunction = (arg: any) => any - -type LengthOfTuple = Tuple extends { length: infer L } - ? L - : never - -type DropFirstInTuple = ((...args: Tuple) => any) extends ( - arg: any, - ...rest: infer LastArg -) => any - ? LastArg - : Tuple - -type LastInTuple = Tuple[LengthOfTuple< - DropFirstInTuple ->] - -type FirstFnParameterType = Parameters< - LastInTuple ->[any] - -type LastFnParameterType = ReturnType< - Functions[0] -> - -/** - * Composes a given list of functions into a new function that - * executes from right to left. - */ -export function compose< - Functions extends ArityOneFunction[], - LeftReturnType extends FirstFnParameterType, - RightReturnType extends LastFnParameterType, ->( - ...fns: Functions -): ( - ...args: [LeftReturnType] extends [never] ? never[] : [LeftReturnType] -) => RightReturnType { - return (...args) => { - return fns.reduceRight((leftFn: any, rightFn) => { - return leftFn instanceof Promise - ? Promise.resolve(leftFn).then(rightFn) - : rightFn(leftFn) - }, args[0]) - } -} diff --git a/src/utils/internal/parseGraphQLRequest.test.ts b/src/utils/internal/parseGraphQLRequest.test.ts deleted file mode 100644 index 58e312e60..000000000 --- a/src/utils/internal/parseGraphQLRequest.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { encodeBuffer } from '@mswjs/interceptors' -import { Headers } from 'headers-polyfill' -import { MockedRequest } from '../request/MockedRequest' -import { parseGraphQLRequest } from './parseGraphQLRequest' - -test('returns true given a GraphQL-compatible request', () => { - const getRequest = new MockedRequest( - new URL( - 'http://localhost:8080/graphql?query=mutation Login { user { id } }', - ), - ) - expect(parseGraphQLRequest(getRequest)).toEqual({ - operationType: 'mutation', - operationName: 'Login', - }) - - const postRequest = new MockedRequest( - new URL('http://localhost:8080/graphql'), - { - method: 'POST', - headers: new Headers({ 'Content-Type': 'application/json' }), - body: encodeBuffer( - JSON.stringify({ - query: `query GetUser { user { firstName } }`, - }), - ), - }, - ) - - expect(parseGraphQLRequest(postRequest)).toEqual({ - operationType: 'query', - operationName: 'GetUser', - }) -}) - -test('throws an exception given an invalid GraphQL request', () => { - const getRequest = new MockedRequest( - new URL('http://localhost:8080/graphql?query=mutation Login() { user { {}'), - ) - expect(() => parseGraphQLRequest(getRequest)).toThrowError( - '[MSW] Failed to intercept a GraphQL request to "GET http://localhost:8080/graphql": cannot parse query. See the error message from the parser below.', - ) - - const postRequest = new MockedRequest( - new URL('http://localhost:8080/graphql'), - { - method: 'POST', - headers: new Headers({ 'Content-Type': 'application/json' }), - body: encodeBuffer( - JSON.stringify({ - query: `query GetUser() { user {{}`, - }), - ), - }, - ) - expect(() => parseGraphQLRequest(postRequest)).toThrowError( - '[MSW] Failed to intercept a GraphQL request to "POST http://localhost:8080/graphql": cannot parse query. See the error message from the parser below.\n\nSyntax Error: Expected "$", found ")".', - ) -}) - -test('returns false given a GraphQL-incompatible request', () => { - const getRequest = new MockedRequest( - new URL('http://localhost:8080/graphql'), - { - headers: new Headers({ 'Content-Type': 'application/json' }), - }, - ) - expect(parseGraphQLRequest(getRequest)).toBeUndefined() - - const postRequest = new MockedRequest( - new URL('http://localhost:8080/graphql'), - { - method: 'POST', - headers: new Headers({ 'Content-Type': 'application/json' }), - body: encodeBuffer( - JSON.stringify({ - queryUser: true, - }), - ), - }, - ) - expect(parseGraphQLRequest(postRequest)).toBeUndefined() -}) diff --git a/src/utils/internal/uuidv4.ts b/src/utils/internal/uuidv4.ts deleted file mode 100644 index 8de04232e..000000000 --- a/src/utils/internal/uuidv4.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function uuidv4() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - const r = (Math.random() * 16) | 0 - const v = c == 'x' ? r : (r & 0x3) | 0x8 - return v.toString(16) - }) -} diff --git a/src/utils/logging/prepareRequest.test.ts b/src/utils/logging/prepareRequest.test.ts deleted file mode 100644 index 1c858ebfc..000000000 --- a/src/utils/logging/prepareRequest.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { encodeBuffer } from '@mswjs/interceptors' -import { Headers } from 'headers-polyfill' -import { MockedRequest } from '../request/MockedRequest' -import { prepareRequest } from './prepareRequest' - -test('converts request headers into an object', () => { - const request = prepareRequest( - new MockedRequest(new URL('http://test.mswjs.io/user'), { - headers: new Headers({ - 'Content-Type': 'text/plain', - 'X-Header': 'secret', - }), - body: encodeBuffer('text-body'), - }), - ) - - expect(request).toEqual( - expect.objectContaining({ - url: new URL('http://test.mswjs.io/user'), - // Converts `Headers` instance into a plain object. - headers: { - 'content-type': 'text/plain', - 'x-header': 'secret', - }, - body: 'text-body', - }), - ) -}) diff --git a/src/utils/logging/prepareRequest.ts b/src/utils/logging/prepareRequest.ts deleted file mode 100644 index b5148f279..000000000 --- a/src/utils/logging/prepareRequest.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { DefaultBodyType } from '../../handlers/RequestHandler.js' -import type { MockedRequest } from '../request/MockedRequest.js' - -export interface LoggedRequest { - id: string - url: URL - method: string - headers: Record - cookies: Record - body: DefaultBodyType -} - -/** - * Formats a mocked request for introspection in browser's console. - */ -export function prepareRequest(request: MockedRequest): LoggedRequest { - return { - ...request, - body: request.body, - headers: request.headers.all(), - } -} diff --git a/src/utils/logging/prepareResponse.test.ts b/src/utils/logging/prepareResponse.test.ts deleted file mode 100644 index c00821873..000000000 --- a/src/utils/logging/prepareResponse.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { prepareResponse } from './prepareResponse' - -test('parses a JSON response body given a "Content-Type:*/json" header', async () => { - const res = await prepareResponse({ - status: 200, - statusText: 'OK', - headers: { - 'Content-Type': 'application/json', - }, - body: `{"property":2}`, - }) - - // Preserves all the properties - expect(res.status).toEqual(200) - expect(res.statusText).toEqual('OK') - expect(res.headers).toEqual({ 'Content-Type': 'application/json' }) - - // Parses a JSON response body - expect(res.body).toEqual({ property: 2 }) -}) - -test('returns a stringified valid JSON body given a non-JSON "Content-Type" header', () => { - const res = prepareResponse({ - status: 200, - statusText: 'OK', - headers: {}, - body: `{"property":2}`, - }) - - expect(res.status).toEqual(200) - expect(res.statusText).toEqual('OK') - expect(res.headers).toEqual({}) - - // Returns a non-JSON response body as-is - expect(res.body).toEqual(`{"property":2}`) -}) - -test('returns a non-JSON response body as-is', () => { - const res = prepareResponse({ - status: 200, - statusText: 'OK', - headers: {}, - body: `text-body`, - }) - - expect(res.status).toEqual(200) - expect(res.statusText).toEqual('OK') - expect(res.headers).toEqual({}) - - // Returns a non-JSON response body as-is - expect(res.body).toEqual('text-body') -}) diff --git a/src/utils/logging/prepareResponse.ts b/src/utils/logging/prepareResponse.ts deleted file mode 100644 index 1fb206094..000000000 --- a/src/utils/logging/prepareResponse.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { objectToHeaders } from 'headers-polyfill' -import { SerializedResponse } from '../../setupWorker/glossary' -import { parseBody } from '../request/parseBody' - -/** - * Formats a mocked response for introspection in the browser's console. - */ -export function prepareResponse(res: SerializedResponse) { - const responseHeaders = objectToHeaders(res.headers) - - // Parse a response JSON body for preview in the logs - const parsedBody = parseBody(res.body, responseHeaders) - - return { - ...res, - body: parsedBody, - } -} diff --git a/src/utils/logging/serializeResponse.ts b/src/utils/logging/serializeResponse.ts deleted file mode 100644 index c46303d75..000000000 --- a/src/utils/logging/serializeResponse.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { flattenHeadersObject, headersToObject } from 'headers-polyfill' -import type { SerializedResponse } from '../../setupWorker/glossary' - -export async function serializeResponse( - response: Response, -): Promise> { - return { - status: response.status, - statusText: response.statusText, - headers: flattenHeadersObject(headersToObject(response.headers)), - // Serialize the response body to a string - // so it's easier to process further down the chain in "prepareResponse" (browser-only) - // and "parseBody" (ambiguous). - body: await response.clone().text(), - } -} diff --git a/src/utils/request/MockedRequest.test.ts b/src/utils/request/MockedRequest.test.ts deleted file mode 100644 index d71294561..000000000 --- a/src/utils/request/MockedRequest.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { Headers } from 'headers-polyfill' -import { clearCookies } from '../../../test/support/utils' -import { MockedRequest } from './MockedRequest' - -const url = new URL('/resource', location.href) - -describe('cache', () => { - it('sets "default" as the default request cache', () => { - const request = new MockedRequest(url) - expect(request.cache).toBe('default') - }) - - it('respects custom request init cache', () => { - const request = new MockedRequest(url, { cache: 'no-cache' }) - expect(request.cache).toBe('no-cache') - }) -}) - -describe('credentials', () => { - it('sets "same-origin" as the default request credentials', () => { - const request = new MockedRequest(url) - expect(request.credentials).toBe('same-origin') - }) - - it('respects custom request init credentials', () => { - const request = new MockedRequest(url, { credentials: 'include' }) - expect(request.credentials).toBe('include') - }) -}) - -describe('destination', () => { - it('sets empty string as the default request destination', () => { - const request = new MockedRequest(url) - expect(request.destination).toBe('') - }) - - it('respects custom request init destination', () => { - const request = new MockedRequest(url, { destination: 'image' }) - expect(request.destination).toBe('image') - }) -}) - -describe('integrity', () => { - it('sets empty string as the default request integrity', () => { - const request = new MockedRequest(url) - expect(request.integrity).toBe('') - }) - - it('respects custom request init integrity', () => { - const request = new MockedRequest(url, { integrity: 'sha256-...' }) - expect(request.integrity).toBe('sha256-...') - }) -}) - -describe('keepalive', () => { - it('sets false as the default request keepalive', () => { - const request = new MockedRequest(url) - expect(request.keepalive).toBe(false) - }) - - it('respects custom request init keepalive', () => { - const request = new MockedRequest(url, { keepalive: true }) - expect(request.keepalive).toBe(true) - }) -}) - -describe('mode', () => { - it('sets "cors" as the default request mode', () => { - const request = new MockedRequest(url) - expect(request.mode).toBe('cors') - }) - - it('respects custom request init mode', () => { - const request = new MockedRequest(url, { mode: 'no-cors' }) - expect(request.mode).toBe('no-cors') - }) -}) - -describe('method', () => { - it('sets "GET" as the default request method', () => { - const request = new MockedRequest(url) - expect(request.method).toBe('GET') - }) - - it('respects custom request init method', () => { - const request = new MockedRequest(url, { method: 'POST' }) - expect(request.method).toBe('POST') - }) -}) - -describe('priority', () => { - it('sets "auto" as the default request priority', () => { - const request = new MockedRequest(url) - expect(request.priority).toBe('auto') - }) - - it('respects custom request init priority', () => { - const request = new MockedRequest(url, { priority: 'high' }) - expect(request.priority).toBe('high') - }) -}) - -describe('redirect', () => { - it('sets "follow" as the default request redirect', () => { - const request = new MockedRequest(url) - expect(request.redirect).toBe('follow') - }) - - it('respects custom request init redirect', () => { - const request = new MockedRequest(url, { redirect: 'error' }) - expect(request.redirect).toBe('error') - }) -}) - -describe('referrer', () => { - it('sets empty string as the default request referrer', () => { - const request = new MockedRequest(url) - expect(request.referrer).toBe('') - }) - - it('respects custom request init referrer', () => { - const request = new MockedRequest(url, { referrer: 'https://example.com' }) - expect(request.referrer).toBe('https://example.com') - }) -}) - -describe('referrerPolicy', () => { - it('sets "no-referrer" as the default request referrerPolicy', () => { - const request = new MockedRequest(url) - expect(request.referrerPolicy).toBe('no-referrer') - }) - - it('respects custom request init referrerPolicy', () => { - const request = new MockedRequest(url, { referrerPolicy: 'origin' }) - expect(request.referrerPolicy).toBe('origin') - }) -}) - -describe('cookies', () => { - beforeAll(() => { - clearCookies() - }) - - afterEach(() => { - clearCookies() - }) - - it('preserves request cookies when there are no document cookies to infer', () => { - const request = new MockedRequest(url, { - headers: new Headers({ Cookie: 'token=abc-123' }), - }) - - expect(request.cookies).toEqual({ token: 'abc-123' }) - expect(request.headers.get('cookie')).toBe('token=abc-123') - }) - - it('infers document cookies for request with "same-origin" credentials', () => { - document.cookie = 'documentCookie=yes' - - const request = new MockedRequest(url, { - headers: new Headers({ Cookie: 'token=abc-123' }), - credentials: 'same-origin', - }) - - expect(request.headers.get('cookie')).toEqual( - 'token=abc-123, documentCookie=yes', - ) - expect(request.cookies).toEqual({ - // Cookies present in the document must be forwarded. - documentCookie: 'yes', - token: 'abc-123', - }) - }) - - it('does not infer document cookies for request with "same-origin" credentials made to extraneous origin', () => { - document.cookie = 'documentCookie=yes' - - const request = new MockedRequest(new URL('https://example.com'), { - headers: new Headers({ Cookie: 'token=abc-123' }), - credentials: 'same-origin', - }) - - expect(request.headers.get('cookie')).toBe('token=abc-123') - expect(request.cookies).toEqual({ token: 'abc-123' }) - }) - - it('infers document cookies for request with "include" credentials', () => { - document.cookie = 'documentCookie=yes' - - const request = new MockedRequest(url, { - headers: new Headers({ Cookie: 'token=abc-123' }), - credentials: 'include', - }) - - expect(request.headers.get('cookie')).toBe( - 'token=abc-123, documentCookie=yes', - ) - expect(request.cookies).toEqual({ - // Cookies present in the document must be forwarded. - documentCookie: 'yes', - token: 'abc-123', - }) - }) - - it('infers document cookies for request with "include" credentials made to extraneous origin', () => { - document.cookie = 'documentCookie=yes' - - const request = new MockedRequest(new URL('https://example.com'), { - headers: new Headers({ Cookie: 'token=abc-123' }), - credentials: 'include', - }) - - expect(request.headers.get('cookie')).toBe( - 'token=abc-123, documentCookie=yes', - ) - expect(request.cookies).toEqual({ - // Document cookies are always included. - documentCookie: 'yes', - token: 'abc-123', - }) - }) - - it('does not infer document cookies for request with "omit" credentials', () => { - document.cookie = 'documentCookie=yes' - - const request = new MockedRequest(url, { - headers: new Headers({ Cookie: 'token=abc-123' }), - credentials: 'omit', - }) - - expect(request.headers.get('cookie')).toBe('token=abc-123') - expect(request.cookies).toEqual({ token: 'abc-123' }) - }) -}) diff --git a/src/utils/request/MockedRequest.ts b/src/utils/request/MockedRequest.ts deleted file mode 100644 index 68d3adf91..000000000 --- a/src/utils/request/MockedRequest.ts +++ /dev/null @@ -1,180 +0,0 @@ -import * as cookieUtils from 'cookie' -import { store } from '@mswjs/cookies' -import { IsomorphicRequest, RequestInit } from '@mswjs/interceptors' -import { decodeBuffer } from '@mswjs/interceptors/lib/utils/bufferUtils.js' -import { Headers } from 'headers-polyfill' -import { DefaultBodyType } from '../../handlers/RequestHandler' -import { MockedResponse } from '../../response' -import { getRequestCookies } from './getRequestCookies' -import { parseBody } from './parseBody' -import { isStringEqual } from '../internal/isStringEqual' - -export type RequestCache = - | 'default' - | 'no-store' - | 'reload' - | 'no-cache' - | 'force-cache' - | 'only-if-cached' - -export type RequestMode = 'navigate' | 'same-origin' | 'no-cors' | 'cors' - -export type RequestRedirect = 'follow' | 'error' | 'manual' - -export type RequestDestination = - | '' - | 'audio' - | 'audioworklet' - | 'document' - | 'embed' - | 'font' - | 'frame' - | 'iframe' - | 'image' - | 'manifest' - | 'object' - | 'paintworklet' - | 'report' - | 'script' - | 'sharedworker' - | 'style' - | 'track' - | 'video' - | 'xslt' - | 'worker' - -export type RequestPriority = 'high' | 'low' | 'auto' - -export type RequestReferrerPolicy = - | '' - | 'no-referrer' - | 'no-referrer-when-downgrade' - | 'origin' - | 'origin-when-cross-origin' - | 'same-origin' - | 'strict-origin' - | 'strict-origin-when-cross-origin' - | 'unsafe-url' - -export interface MockedRequestInit extends RequestInit { - id?: string - cache?: RequestCache - redirect?: RequestRedirect - integrity?: string - keepalive?: boolean - mode?: RequestMode - priority?: RequestPriority - destination?: RequestDestination - referrer?: string - referrerPolicy?: RequestReferrerPolicy - cookies?: Record -} - -export class MockedRequest< - RequestBody extends DefaultBodyType = DefaultBodyType, -> extends IsomorphicRequest { - public readonly cache: RequestCache - public readonly cookies: Record - public readonly destination: RequestDestination - public readonly integrity: string - public readonly keepalive: boolean - public readonly mode: RequestMode - public readonly priority: RequestPriority - public readonly redirect: RequestRedirect - public readonly referrer: string - public readonly referrerPolicy: RequestReferrerPolicy - - constructor(url: URL, init: MockedRequestInit = {}) { - super(url, init) - if (init.id) { - this.id = init.id - } - this.cache = init.cache || 'default' - this.destination = init.destination || '' - this.integrity = init.integrity || '' - this.keepalive = init.keepalive || false - this.mode = init.mode || 'cors' - this.priority = init.priority || 'auto' - this.redirect = init.redirect || 'follow' - this.referrer = init.referrer || '' - this.referrerPolicy = init.referrerPolicy || 'no-referrer' - this.cookies = init.cookies || this.getCookies() - } - - /** - * Get parsed request body. The type is inferred from the content type. - * - * @deprecated - Use `req.text()`, `req.json()` or `req.arrayBuffer()` - * to read the request body as a plain text, JSON, or ArrayBuffer. - */ - public get body(): RequestBody { - const text = decodeBuffer(this['_body']) - - /** - * @deprecated https://github.com/mswjs/msw/issues/1318 - * @fixme Remove this assumption and let the users read - * request body explicitly using ".json()"/".text()"/".arrayBuffer()". - */ - // Parse the request's body based on the "Content-Type" header. - const body = parseBody(text, this.headers) - - if (isStringEqual(this.method, 'GET') && body === '') { - return undefined as RequestBody - } - - return body as RequestBody - } - - /** - * Bypass the intercepted request. - * This will make a call to the actual endpoint requested. - */ - public passthrough(): MockedResponse { - return { - // Constructing a dummy "101 Continue" mocked response - // to keep the return type of the resolver consistent. - status: 101, - statusText: 'Continue', - headers: new Headers(), - body: null, - // Setting "passthrough" to true will signal the response pipeline - // to perform this intercepted request as-is. - passthrough: true, - once: false, - } - } - - private getCookies(): Record { - // Parse the cookies passed in the original request "cookie" header. - const requestCookiesString = this.headers.get('cookie') - const ownCookies = requestCookiesString - ? cookieUtils.parse(requestCookiesString) - : {} - - store.hydrate() - - const cookiesFromStore = Array.from( - store.get({ ...this, url: this.url.href })?.entries(), - ).reduce((cookies, [name, { value }]) => { - return Object.assign(cookies, { [name.trim()]: value }) - }, {}) - - // Get existing document cookies that are applicable - // to this request based on its "credentials" policy. - const cookiesFromDocument = getRequestCookies(this) - - const forwardedCookies = { - ...cookiesFromDocument, - ...cookiesFromStore, - } - - for (const [name, value] of Object.entries(forwardedCookies)) { - this.headers.append('cookie', `${name}=${value}`) - } - - return { - ...forwardedCookies, - ...ownCookies, - } - } -} diff --git a/src/utils/request/createResponseFromIsomorphicResponse.ts b/src/utils/request/createResponseFromIsomorphicResponse.ts deleted file mode 100644 index 759d5cd26..000000000 --- a/src/utils/request/createResponseFromIsomorphicResponse.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsomorphicResponse } from '@mswjs/interceptors' - -export function createResponseFromIsomorphicResponse( - response: IsomorphicResponse, -): Response { - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }) -} diff --git a/src/utils/request/getPublicUrlFromRequest.test.ts b/src/utils/request/getPublicUrlFromRequest.test.ts deleted file mode 100644 index 16fdbb3a7..000000000 --- a/src/utils/request/getPublicUrlFromRequest.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { getPublicUrlFromRequest } from './getPublicUrlFromRequest' -import { MockedRequest } from './MockedRequest' - -test('returns an absolute URL string given its origin differs from the referrer', () => { - const request = new MockedRequest(new URL('https://test.mswjs.io/path'), { - referrer: 'http://localhost', - }) - expect(getPublicUrlFromRequest(request)).toBe('https://test.mswjs.io/path') -}) - -test('returns a relative URL string given its origin matches the referrer', () => { - const request = new MockedRequest(new URL('http://localhost/path'), { - referrer: 'http://localhost', - }) - expect(getPublicUrlFromRequest(request)).toBe('/path') -}) diff --git a/src/utils/request/getPublicUrlFromRequest.ts b/src/utils/request/getPublicUrlFromRequest.ts deleted file mode 100644 index f540a2143..000000000 --- a/src/utils/request/getPublicUrlFromRequest.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { MockedRequest } from './MockedRequest' - -/** - * Returns a relative URL if the given request URL is relative to the current origin. - * Otherwise returns an absolute URL. - */ -export const getPublicUrlFromRequest = (request: MockedRequest) => { - return request.referrer.startsWith(request.url.origin) - ? request.url.pathname - : new URL( - request.url.pathname, - `${request.url.protocol}//${request.url.host}`, - ).href -} diff --git a/src/utils/request/getRequestCookies.ts b/src/utils/request/getRequestCookies.ts deleted file mode 100644 index ef85b1a70..000000000 --- a/src/utils/request/getRequestCookies.ts +++ /dev/null @@ -1,35 +0,0 @@ -import * as cookieUtils from 'cookie' -import { MockedRequest } from './MockedRequest' - -function getAllCookies() { - return cookieUtils.parse(document.cookie) -} - -/** - * Returns relevant document cookies based on the request `credentials` option. - */ -export function getRequestCookies(request: MockedRequest) { - /** - * @note No cookies persist on the document in Node.js: no document. - */ - if (typeof document === 'undefined' || typeof location === 'undefined') { - return {} - } - - switch (request.credentials) { - case 'same-origin': { - // Return document cookies only when requested a resource - // from the same origin as the current document. - return location.origin === request.url.origin ? getAllCookies() : {} - } - - case 'include': { - // Return all document cookies. - return getAllCookies() - } - - default: { - return {} - } - } -} diff --git a/src/utils/request/parseBody.test.ts b/src/utils/request/parseBody.test.ts deleted file mode 100644 index dd204baab..000000000 --- a/src/utils/request/parseBody.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { parseBody } from './parseBody' - -test('parses a body if the "Content-Type:application/json" header is set', () => { - expect( - parseBody( - `{"property":2}`, - new Headers({ 'Content-Type': 'application/json' }), - ), - ).toEqual({ - property: 2, - }) -}) - -test('parses a body for headers with letter cases', () => { - expect( - parseBody( - `{"property":2}`, - new Headers({ 'Content-Type': 'Application/JSON' }), - ), - ).toEqual({ - property: 2, - }) -}) - -test('parses a body if the "Content-Type*/json" header is set', () => { - expect( - parseBody( - `{"property":2}`, - new Headers({ 'Content-Type': 'application/hal+json' }), - ), - ).toEqual({ - property: 2, - }) -}) - -test('parses a body if the "Content-Type:application/json; charset=UTF-8" header is set', () => { - expect( - parseBody( - `{"property":2}`, - new Headers({ 'Content-Type': 'application/json; charset=UTF-8' }), - ), - ).toEqual({ - property: 2, - }) -}) - -test('returns an invalid JSON body as-is even if the "Content-Type:*/json" header is set', () => { - expect( - parseBody('text-body', new Headers({ 'Content-Type': 'application/json' })), - ).toBe('text-body') -}) - -test('parses a body if the "Content-Type: multipart/form-data" header is set', () => { - const body = `\ -------WebKitFormBoundaryvZ1cVXWyK0ilQdab\r -Content-Disposition: form-data; name="file"; filename="file1.txt"\r -Content-Type: application/octet-stream\r -\r -file content\r -------WebKitFormBoundaryvZ1cVXWyK0ilQdab\r -Content-Disposition: form-data; name="text"\r -\r -text content\r -------WebKitFormBoundaryvZ1cVXWyK0ilQdab\r -Content-Disposition: form-data; name="text2"\r -\r -another text content\r -------WebKitFormBoundaryvZ1cVXWyK0ilQdab\r -Content-Disposition: form-data; name="text2"\r -\r -another text content 2\r -------WebKitFormBoundaryvZ1cVXWyK0ilQdab--` - const headers = new Headers({ - 'content-type': - 'multipart/form-data; boundary=--WebKitFormBoundaryvZ1cVXWyK0ilQdab', - }) - const parsed = parseBody(body, headers) - - // Workaround: JSDOM does not have `Blob.text` implementation. - // see https://github.com/jsdom/jsdom/issues/2555 - expect(parsed).toHaveProperty('file.name', 'file1.txt') - - expect(parsed).toHaveProperty('text', 'text content') - expect(parsed).toHaveProperty('text2', [ - 'another text content', - 'another text content 2', - ]) -}) - -test('returns an invalid Multipart body as-is even if the "Content-Type: multipart/form-data" header is set', () => { - const headers = new Headers({ - 'content-type': - 'multipart/form-data; boundary=------WebKitFormBoundaryvZ1cVXWyK0ilQdab', - }) - expect(parseBody('text-body', headers)).toEqual('text-body') -}) - -test('parses a single stringified number as a valid "application/json" body', () => { - const headers = new Headers({ 'Content-Type': 'application/json' }) - expect(parseBody('1', headers)).toEqual(1) -}) - -test('preserves a single stringified number in a "multipart/form-data" body', () => { - const headers = new Headers({ 'Content-Type': 'multipart/form-data;' }) - expect(parseBody('1', headers)).toEqual('1') -}) - -test('returns a falsy body as-is', () => { - expect(parseBody('')).toEqual('') - expect(parseBody(undefined)).toBeUndefined() -}) diff --git a/src/utils/request/parseBody.ts b/src/utils/request/parseBody.ts deleted file mode 100644 index 221601153..000000000 --- a/src/utils/request/parseBody.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { jsonParse } from '../internal/jsonParse' -import { parseMultipartData } from '../internal/parseMultipartData' -import { MockedRequest } from './MockedRequest' - -/** - * Parses a given request/response body based on the "Content-Type" header. - */ -export function parseBody(body?: MockedRequest['body'], headers?: Headers) { - // Return whatever falsey body value is given. - if (!body) { - return body - } - - const contentType = headers?.get('content-type')?.toLowerCase() || '' - - // If the body has a Multipart Content-Type - // parse it into an object. - const hasMultipartContent = contentType.startsWith('multipart/form-data') - if (hasMultipartContent && typeof body !== 'object') { - return parseMultipartData(body.toString(), headers) || body - } - - // If the intercepted request's body has a JSON Content-Type - // parse it into an object. - const hasJsonContent = contentType.includes('json') - - if (hasJsonContent && typeof body !== 'object') { - return jsonParse(body.toString()) || body - } - - // Otherwise leave as-is. - return body -} diff --git a/src/utils/request/parseWorkerRequest.ts b/src/utils/request/parseWorkerRequest.ts deleted file mode 100644 index cc8b45fd7..000000000 --- a/src/utils/request/parseWorkerRequest.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { encodeBuffer } from '@mswjs/interceptors' -import { Headers } from 'headers-polyfill' -import { ServiceWorkerIncomingRequest } from '../../setupWorker/glossary' -import { MockedRequest } from './MockedRequest' - -/** - * Converts a given request received from the Service Worker - * into a `MockedRequest` instance. - */ -export function parseWorkerRequest( - rawRequest: ServiceWorkerIncomingRequest, -): MockedRequest { - const url = new URL(rawRequest.url) - const headers = new Headers(rawRequest.headers) - - return new MockedRequest(url, { - ...rawRequest, - body: encodeBuffer(rawRequest.body || ''), - headers, - }) -} diff --git a/src/utils/request/pruneGetRequestBody.test.ts b/src/utils/request/pruneGetRequestBody.test.ts deleted file mode 100644 index 1c08e25a4..000000000 --- a/src/utils/request/pruneGetRequestBody.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { pruneGetRequestBody } from './pruneGetRequestBody' - -test('sets empty GET request body to undefined', () => { - const body = pruneGetRequestBody({ - method: 'GET', - body: '', - }) - - expect(body).toBeUndefined() -}) - -test('preserves non-empty GET request body', () => { - const body = pruneGetRequestBody({ - method: 'GET', - body: 'text-body', - }) - - expect(body).toBe('text-body') -}) - -test('ignores requests of the other method than GET', () => { - expect( - pruneGetRequestBody({ - method: 'HEAD', - body: JSON.stringify({ a: 1 }), - }), - ).toBe(JSON.stringify({ a: 1 })) - - expect( - pruneGetRequestBody({ - method: 'POST', - body: 'text-body', - }), - ).toBe('text-body') -}) diff --git a/src/utils/request/pruneGetRequestBody.ts b/src/utils/request/pruneGetRequestBody.ts deleted file mode 100644 index d6f1e1ce7..000000000 --- a/src/utils/request/pruneGetRequestBody.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ServiceWorkerIncomingRequest } from '../../setupWorker/glossary' -import { isStringEqual } from '../internal/isStringEqual' - -type Input = Pick - -/** - * Ensures that an empty GET request body is always represented as `undefined`. - */ -export function pruneGetRequestBody( - request: Input, -): ServiceWorkerIncomingRequest['body'] { - if ( - request.method && - isStringEqual(request.method, 'GET') && - request.body === '' - ) { - return undefined - } - - return request.body -} diff --git a/test/browser/graphql-api/anonymous-operation.mocks.ts b/test/browser/graphql-api/anonymous-operation.mocks.ts new file mode 100644 index 000000000..92647efc5 --- /dev/null +++ b/test/browser/graphql-api/anonymous-operation.mocks.ts @@ -0,0 +1,12 @@ +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker() +worker.start() + +// @ts-ignore +window.msw = { + worker, + graphql, + HttpResponse, +} diff --git a/test/browser/graphql-api/anonymous-operation.test.ts b/test/browser/graphql-api/anonymous-operation.test.ts new file mode 100644 index 000000000..efc6a2479 --- /dev/null +++ b/test/browser/graphql-api/anonymous-operation.test.ts @@ -0,0 +1,210 @@ +import { HttpServer } from '@open-draft/test-server/http' +import { test, expect } from '../playwright.extend' +import { gql } from '../../support/graphql' +import { waitFor } from '../../support/waitFor' + +declare namespace window { + export const msw: { + worker: import('msw/browser').SetupWorkerApi + graphql: typeof import('msw').graphql + HttpResponse: typeof import('msw').HttpResponse + } +} + +const httpServer = new HttpServer((app) => { + app.post('/graphql', (req, res) => { + res.json({ + data: { + user: { + id: 'abc-123', + }, + }, + }) + }) +}) + +test.beforeAll(async () => { + await httpServer.listen() +}) + +test.afterAll(async () => { + await httpServer.close() +}) + +test('does not warn on anonymous GraphQL operation when no GraphQL handlers are present', async ({ + loadExample, + query, + spyOnConsole, +}) => { + await loadExample(require.resolve('./anonymous-operation.mocks.ts')) + const consoleSpy = spyOnConsole() + + const endpointUrl = httpServer.http.url('/graphql') + const response = await query(endpointUrl, { + query: gql` + # Intentionally anonymous query. + query { + user { + id + } + } + `, + }) + + const json = await response.json() + + // Must get the original server response. + expect(json).toEqual({ + data: { + user: { + id: 'abc-123', + }, + }, + }) + + await waitFor(() => { + // Must print a generic unhandled GraphQL request warning. + // This has nothing to do with the operation being anonymous. + expect(consoleSpy.get('warning')).toEqual([ + `\ +[MSW] Warning: intercepted a request without a matching request handler: + + • anonymous query (POST ${endpointUrl}) + +If you still wish to intercept this unhandled request, please create a request handler for it. +Read more: https://mswjs.io/docs/getting-started/mocks`, + ]) + }) + + // // 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( + // expect.arrayContaining([ + // `[MSW] Failed to intercept a GraphQL request at "POST ${endpointUrl}": 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`, + // ]), + // ) + // }) +}) + +test('warns on handled anonymous GraphQL operation', async ({ + loadExample, + query, + spyOnConsole, + page, +}) => { + await loadExample(require.resolve('./anonymous-operation.mocks.ts')) + const consoleSpy = spyOnConsole() + + await page.evaluate(() => { + const { worker, graphql, HttpResponse } = window.msw + + worker.use( + // This handler will have no effect on the anonymous operation performed. + graphql.query('IrrelevantQuery', () => { + return HttpResponse.json({ + data: { + user: { + id: 'mocked-123', + }, + }, + }) + }), + ) + }) + + const endpointUrl = httpServer.http.url('/graphql') + const response = await query(endpointUrl, { + query: gql` + # Intentionally anonymous query. + # It will be handled in the "graphql.operation()" handler above. + query { + user { + id + } + } + `, + }) + + const json = await response.json() + + // Must get the original response because the "graphql.query()" + // handler won't match an anonymous GraphQL operation. + expect(json).toEqual({ + data: { + user: { + id: 'abc-123', + }, + }, + }) + + // Must print the warning because an anonymous operation has been performed. + await waitFor(() => { + expect(consoleSpy.get('warning')).toEqual( + expect.arrayContaining([ + `[MSW] Failed to intercept a GraphQL request at "POST ${endpointUrl}": 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`, + ]), + ) + }) +}) + +test('does not print a warning on anonymous GraphQL operation handled by "graphql.operation()"', async ({ + loadExample, + spyOnConsole, + page, + query, +}) => { + await loadExample(require.resolve('./anonymous-operation.mocks.ts')) + const consoleSpy = spyOnConsole() + + await page.evaluate(() => { + const { worker, graphql, HttpResponse } = window.msw + + worker.use( + // This handler will match ANY anonymous GraphQL operation. + // It's a good idea to include some matching logic to differentiate + // between those operations. We're omitting it for testing purposes. + graphql.operation(() => { + return HttpResponse.json({ + data: { + user: { + id: 'mocked-123', + }, + }, + }) + }), + ) + }) + + const endpointUrl = httpServer.http.url('/graphql') + const response = await query(endpointUrl, { + query: gql` + # Intentionally anonymous query. + # It will be handled in the "graphql.operation()" handler above. + query { + user { + id + } + } + `, + }) + + const json = await response.json() + + // Must get the mocked response. + expect(json).toEqual({ + data: { + user: { + id: 'mocked-123', + }, + }, + }) + + // Must not print any warnings because a permissive "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/cookies.mocks.ts b/test/browser/graphql-api/cookies.mocks.ts index 66c5460cb..7cd387da2 100644 --- a/test/browser/graphql-api/cookies.mocks.ts +++ b/test/browser/graphql-api/cookies.mocks.ts @@ -1,12 +1,19 @@ -import { setupWorker, graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - graphql.query('GetUser', (req, res, ctx) => { - return res( - ctx.cookie('test-cookie', 'value'), - ctx.data({ - firstName: 'John', - }), + graphql.query('GetUser', () => { + return HttpResponse.json( + { + data: { + firstName: 'John', + }, + }, + { + headers: { + 'Set-Cookie': 'test-cookie=value', + }, + }, ) }), ) diff --git a/test/browser/graphql-api/cookies.test.ts b/test/browser/graphql-api/cookies.test.ts index a94805ad9..c451f8dcc 100644 --- a/test/browser/graphql-api/cookies.test.ts +++ b/test/browser/graphql-api/cookies.test.ts @@ -20,7 +20,7 @@ test('sets cookie on the mocked GraphQL response', async ({ const headers = await res.allHeaders() const body = await res.json() - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(headers).not.toHaveProperty('set-cookie') expect(body).toEqual({ data: { diff --git a/test/browser/graphql-api/document-node.mocks.ts b/test/browser/graphql-api/document-node.mocks.ts index 399118d0c..38d66056e 100644 --- a/test/browser/graphql-api/document-node.mocks.ts +++ b/test/browser/graphql-api/document-node.mocks.ts @@ -1,5 +1,6 @@ import { parse } from 'graphql' -import { setupWorker, graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const GetUser = parse(` query GetUser { @@ -32,38 +33,38 @@ const github = graphql.link('https://api.github.com/graphql') const worker = setupWorker( // "DocumentNode" can be used as the expected query/mutation. - graphql.query(GetUser, (req, res, ctx) => { - return res( - ctx.data({ + graphql.query(GetUser, () => { + return HttpResponse.json({ + data: { // Note that inferring the query body and variables // is impossible with the native "DocumentNode". // Consider using tools like GraphQL Code Generator. user: { firstName: 'John', }, - }), - ) + }, + }) }), - graphql.mutation(Login, (req, res, ctx) => { - return res( - ctx.data({ + graphql.mutation(Login, ({ variables }) => { + return HttpResponse.json({ + data: { session: { id: 'abc-123', }, user: { - username: req.variables.username, + username: variables.username, }, - }), - ) + }, + }) }), - github.query(GetSubscription, (req, res, ctx) => { - return res( - ctx.data({ + github.query(GetSubscription, () => { + return HttpResponse.json({ + data: { subscription: { id: 123, }, - }), - ) + }, + }) }), ) diff --git a/test/browser/graphql-api/errors.mocks.ts b/test/browser/graphql-api/errors.mocks.ts index 46fe08634..18b98088a 100644 --- a/test/browser/graphql-api/errors.mocks.ts +++ b/test/browser/graphql-api/errors.mocks.ts @@ -1,9 +1,10 @@ -import { setupWorker, graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - graphql.query('Login', (req, res, ctx) => { - return res( - ctx.errors([ + graphql.query('Login', () => { + return HttpResponse.json({ + errors: [ { message: 'This is a mocked error', locations: [ @@ -13,8 +14,8 @@ const worker = setupWorker( }, ], }, - ]), - ) + ], + }) }), ) diff --git a/test/browser/graphql-api/extensions.mocks.ts b/test/browser/graphql-api/extensions.mocks.ts index 28246c6db..8981bf8cb 100644 --- a/test/browser/graphql-api/extensions.mocks.ts +++ b/test/browser/graphql-api/extensions.mocks.ts @@ -1,23 +1,32 @@ -import { setupWorker, graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' + +interface LoginQuery { + user: { + id: number + name: string + password: string + } +} const worker = setupWorker( - graphql.query('Login', (_req, res, ctx) => { - return res( - ctx.data({ + graphql.query('Login', () => { + return HttpResponse.json({ + data: { user: { id: 1, name: 'Joe Bloggs', password: 'HelloWorld!', }, - }), - ctx.extensions({ + }, + extensions: { message: 'This is a mocked extension', tracking: { version: '0.1.2', page: '/test/', }, - }), - ) + }, + }) }), ) diff --git a/test/browser/graphql-api/link.mocks.ts b/test/browser/graphql-api/link.mocks.ts index 8481c35b7..9e25b356e 100644 --- a/test/browser/graphql-api/link.mocks.ts +++ b/test/browser/graphql-api/link.mocks.ts @@ -1,4 +1,5 @@ -import { setupWorker, graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const github = graphql.link('https://api.github.com/graphql') const stripe = graphql.link('https://api.stripe.com/graphql') @@ -24,36 +25,51 @@ interface GetUserQuery { } const worker = setupWorker( - github.query('GetUser', (req, res, ctx) => { - return res( - ctx.data({ - user: { - id: '46cfe8ff-a79b-42af-9699-b56e2239d1bb', - username: req.variables.username, + github.query( + 'GetUser', + ({ variables }) => { + return HttpResponse.json({ + data: { + user: { + id: '46cfe8ff-a79b-42af-9699-b56e2239d1bb', + username: variables.username, + }, }, - }), - ) - }), - stripe.mutation('Payment', (req, res, ctx) => { - return res( - ctx.data({ - bankAccount: { - totalFunds: 100 + req.variables.amount, + }) + }, + ), + stripe.mutation( + 'Payment', + ({ variables }) => { + return HttpResponse.json({ + data: { + bankAccount: { + totalFunds: 100 + variables.amount, + }, }, - }), - ) - }), - graphql.query('GetUser', (req, res, ctx) => { - return res( - ctx.set('x-request-handler', 'fallback'), - ctx.data({ - user: { - id: '46cfe8ff-a79b-42af-9699-b56e2239d1bb', - username: req.variables.username, + }) + }, + ), + graphql.query( + 'GetUser', + ({ variables }) => { + return HttpResponse.json( + { + data: { + user: { + id: '46cfe8ff-a79b-42af-9699-b56e2239d1bb', + username: variables.username, + }, + }, }, - }), - ) - }), + { + headers: { + 'X-Request-Handler': 'fallback', + }, + }, + ) + }, + ), ) worker.start() diff --git a/test/browser/graphql-api/link.test.ts b/test/browser/graphql-api/link.test.ts index a29a48315..58e35a15f 100644 --- a/test/browser/graphql-api/link.test.ts +++ b/test/browser/graphql-api/link.test.ts @@ -41,7 +41,7 @@ test('mocks a GraphQL query to the GitHub GraphQL API', async ({ const headers = await res.allHeaders() const body = await res.json() - expect(res.status()).toEqual(200) + expect(res.status()).toBe(200) expect(headers).toHaveProperty('content-type', 'application/json') expect(body).toEqual({ data: { @@ -75,7 +75,7 @@ test('mocks a GraphQL mutation to the Stripe GraphQL API', async ({ const headers = await res.allHeaders() const body = await res.json() - expect(res.status()).toEqual(200) + expect(res.status()).toBe(200) expect(headers).toHaveProperty('content-type', 'application/json') expect(body).toEqual({ data: { diff --git a/test/browser/graphql-api/logging.mocks.ts b/test/browser/graphql-api/logging.mocks.ts index 515fae03c..b24f7a270 100644 --- a/test/browser/graphql-api/logging.mocks.ts +++ b/test/browser/graphql-api/logging.mocks.ts @@ -1,4 +1,5 @@ -import { setupWorker, graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' interface GetUserDetailQuery { user: { @@ -14,31 +15,35 @@ interface LoginQuery { } const worker = setupWorker( - graphql.query('GetUserDetail', (req, res, ctx) => { - return res( - ctx.data({ + graphql.query('GetUserDetail', () => { + return HttpResponse.json({ + data: { user: { firstName: 'John', lastName: 'Maverick', }, - }), - ) + }, + }) }), - graphql.mutation('Login', (req, res, ctx) => { - return res( - ctx.data({ + graphql.mutation('Login', () => { + return HttpResponse.json({ + data: { user: { id: 'abc-123', }, - }), - ) + }, + }) }), - graphql.operation((req, res, ctx) => { - return res( - ctx.status(301), - ctx.data({ - ok: true, - }), + graphql.operation(() => { + return HttpResponse.json( + { + data: { + ok: true, + }, + }, + { + status: 301, + }, ) }), ) diff --git a/test/browser/graphql-api/logging.test.ts b/test/browser/graphql-api/logging.test.ts index 99690510f..ee7d57fdd 100644 --- a/test/browser/graphql-api/logging.test.ts +++ b/test/browser/graphql-api/logging.test.ts @@ -1,4 +1,4 @@ -import { StatusCodeColor } from '../../../src/utils/logging/getStatusCodeColor' +import { StatusCodeColor } from '../../../src/core/utils/logging/getStatusCodeColor' import { waitFor } from '../../support/waitFor' import { test, expect } from '../playwright.extend' import { gql } from '../../support/graphql' @@ -25,7 +25,7 @@ test('prints a log for a GraphQL query', async ({ }) await waitFor(() => { - expect(consoleSpy.get('raw').get('startGroupCollapsed')).toEqual( + expect(consoleSpy.get('raw')?.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( new RegExp( @@ -56,7 +56,7 @@ test('prints a log for a GraphQL mutation', async ({ }) await waitFor(() => { - expect(consoleSpy.get('raw').get('startGroupCollapsed')).toEqual( + expect(consoleSpy.get('raw')?.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( new RegExp( @@ -87,7 +87,7 @@ test('prints a log for a GraphQL query intercepted via "graphql.operation"', asy }) await waitFor(() => { - expect(consoleSpy.get('raw').get('startGroupCollapsed')).toEqual( + expect(consoleSpy.get('raw')?.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( new RegExp( @@ -118,7 +118,7 @@ test('prints a log for a GraphQL mutation intercepted via "graphql.operation"', }) await waitFor(() => { - expect(consoleSpy.get('raw').get('startGroupCollapsed')).toEqual( + expect(consoleSpy.get('raw')?.get('startGroupCollapsed')).toEqual( expect.arrayContaining([ expect.stringMatching( new RegExp( diff --git a/test/browser/graphql-api/multipart-data.mocks.ts b/test/browser/graphql-api/multipart-data.mocks.ts index 9f971fd88..4732768e5 100644 --- a/test/browser/graphql-api/multipart-data.mocks.ts +++ b/test/browser/graphql-api/multipart-data.mocks.ts @@ -1,40 +1,37 @@ -import { setupWorker, graphql } from 'msw' - -interface UploadFileMutation { - multipart: { - file1?: string - file2?: string - files?: string[] - otherVariable?: string - } -} - -interface UploadFileVariables { - file1?: File - file2?: File - files?: File[] - otherVariable?: string -} +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - graphql.mutation( - 'UploadFile', - async (req, res, ctx) => { - const { file1, file2, files = [], otherVariable } = req.variables - const filesResponse = await Promise.all(files.map((file) => file.text())) - - return res( - ctx.data({ - multipart: { - file1: await file1?.text(), - file2: await file2?.text(), - files: filesResponse, - otherVariable, - }, - }), - ) + graphql.mutation< + { + multipart: { + file1?: string + file2?: string + files?: Array + plainText?: string + } }, - ), + { + file1?: File + file2?: File + files?: Array + plainText?: string + } + >('UploadFile', async ({ variables }) => { + const { file1, file2, files = [], plainText } = variables + const filesResponse = await Promise.all(files.map((file) => file.text())) + + return HttpResponse.json({ + data: { + multipart: { + file1: await file1?.text(), + file2: await file2?.text(), + files: filesResponse, + plainText, + }, + }, + }) + }), ) worker.start() diff --git a/test/browser/graphql-api/multipart-data.test.ts b/test/browser/graphql-api/multipart-data.test.ts index 0ead2e067..e5a48cafd 100644 --- a/test/browser/graphql-api/multipart-data.test.ts +++ b/test/browser/graphql-api/multipart-data.test.ts @@ -1,5 +1,4 @@ import { test, expect } from '../playwright.extend' -import { gql } from '../../support/graphql' test('accepts a file from a GraphQL mutation', async ({ loadExample, @@ -7,22 +6,31 @@ test('accepts a file from a GraphQL mutation', async ({ }) => { await loadExample(require.resolve('./multipart-data.mocks.ts')) - const UPLOAD_FILE_MUTATION = gql` - mutation UploadFile($file1: Upload, $file2: Upload, $plainText: String) { - multipart(file1: $file1, file2: $file2, plainText: $plainText) { + const UPLOAD_MUTATION = ` + mutation UploadFile( + $file1: Upload + $file2: Upload + $plainText: String + ) { + multipart( + file1: $file1 + file2: $file2 + plainText: $plainText + ){ file1 file2 plainText } } ` + const res = await query('/graphql', { - query: UPLOAD_FILE_MUTATION, + query: UPLOAD_MUTATION, variables: { file1: null, file2: null, files: [null, null], - otherVariable: 'value', + plainText: 'text', }, multipartOptions: { map: { @@ -42,7 +50,7 @@ test('accepts a file from a GraphQL mutation', async ({ file1: 'file1 content', file2: 'file2 content', files: ['file1 content', 'file2 content'], - otherVariable: 'value', + plainText: 'text', }, }, }) diff --git a/test/browser/graphql-api/mutation.mocks.ts b/test/browser/graphql-api/mutation.mocks.ts index e748425c1..1a6253433 100644 --- a/test/browser/graphql-api/mutation.mocks.ts +++ b/test/browser/graphql-api/mutation.mocks.ts @@ -1,4 +1,5 @@ -import { setupWorker, graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' interface LogoutQuery { logout: { @@ -7,14 +8,14 @@ interface LogoutQuery { } const worker = setupWorker( - graphql.mutation('Logout', (req, res, ctx) => { - return res( - ctx.data({ + graphql.mutation('Logout', () => { + return HttpResponse.json({ + data: { logout: { userSession: false, }, - }), - ) + }, + }) }), ) 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/operation-reference.mocks.ts b/test/browser/graphql-api/operation-reference.mocks.ts index f291c5c88..a0137d52c 100644 --- a/test/browser/graphql-api/operation-reference.mocks.ts +++ b/test/browser/graphql-api/operation-reference.mocks.ts @@ -1,21 +1,22 @@ -import { setupWorker, graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - graphql.query('GetUser', (req, res, ctx) => { - return res( - ctx.data({ - query: req.body.query, - variables: req.body.variables, - }), - ) + graphql.query('GetUser', async ({ query, variables }) => { + return HttpResponse.json({ + data: { + query, + variables, + }, + }) }), - graphql.mutation('Login', (req, res, ctx) => { - return res( - ctx.data({ - query: req.body.query, - variables: req.body.variables, - }), - ) + graphql.mutation('Login', ({ query, variables }) => { + return HttpResponse.json({ + data: { + query, + variables, + }, + }) }), ) diff --git a/test/browser/graphql-api/operation-reference.test.ts b/test/browser/graphql-api/operation-reference.test.ts index 5c7f7663f..7e4850d66 100644 --- a/test/browser/graphql-api/operation-reference.test.ts +++ b/test/browser/graphql-api/operation-reference.test.ts @@ -23,11 +23,10 @@ test('allows referencing the request body in the GraphQL query handler', async ( id: 'abc-123', }, }) - const headers = await res.allHeaders() const body = await res.json() expect(res.status()).toBe(200) - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(body).toEqual({ data: { query: GET_USER_QUERY, @@ -58,11 +57,10 @@ test('allows referencing the request body in the GraphQL mutation handler', asyn password: 'super-secret', }, }) - const headers = await res.allHeaders() const body = await res.json() expect(res.status()).toBe(200) - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(body).toEqual({ data: { query: LOGIN_MUTATION, diff --git a/test/browser/graphql-api/operation.mocks.ts b/test/browser/graphql-api/operation.mocks.ts index d1f56b2fb..f5ecc0fc4 100644 --- a/test/browser/graphql-api/operation.mocks.ts +++ b/test/browser/graphql-api/operation.mocks.ts @@ -1,13 +1,14 @@ -import { setupWorker, graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - graphql.operation((req, res, ctx) => { - return res( - ctx.data({ - query: req.body.query, - variables: req.body.variables, - }), - ) + graphql.operation(async ({ query, variables }) => { + return HttpResponse.json({ + data: { + query, + variables, + }, + }) }), ) diff --git a/test/browser/graphql-api/operation.test.ts b/test/browser/graphql-api/operation.test.ts index 193e9dcf9..2658867bc 100644 --- a/test/browser/graphql-api/operation.test.ts +++ b/test/browser/graphql-api/operation.test.ts @@ -39,11 +39,10 @@ test('intercepts and mocks a GraphQL query', async ({ id: 'abc-123', }, }) - const headers = await res.allHeaders() const body = await res.json() expect(res.status()).toBe(200) - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(body).toEqual({ data: { query: GET_USER_QUERY, @@ -87,9 +86,7 @@ test('intercepts and mocks an anonymous GraphQL query', async ({ expect(consoleSpy.get('warning')).toBeUndefined() expect(res.status()).toBe(200) - - const headers = await res.allHeaders() - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) const body = await res.json() expect(body).toEqual({ @@ -128,11 +125,10 @@ test('intercepts and mocks a GraphQL mutation', async ({ password: 'super-secret', }, }) - const headers = await res.allHeaders() const body = await res.json() expect(res.status()).toBe(200) - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(body).toEqual({ data: { query: LOGIN_MUTATION, @@ -184,12 +180,10 @@ test('bypasses seemingly compatible REST requests', async ({ const res = await query(server.http.url('/search'), { query: 'favorite books', }) - - const headers = await res.allHeaders() const body = await res.json() expect(res.status()).toBe(200) - expect(headers).not.toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(body).toEqual({ results: [1, 2, 3], }) diff --git a/test/browser/graphql-api/query.mocks.ts b/test/browser/graphql-api/query.mocks.ts index e701b1326..740a4d8f9 100644 --- a/test/browser/graphql-api/query.mocks.ts +++ b/test/browser/graphql-api/query.mocks.ts @@ -1,4 +1,5 @@ -import { setupWorker, graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' interface GetUserDetailQuery { user: { @@ -8,15 +9,15 @@ interface GetUserDetailQuery { } const worker = setupWorker( - graphql.query('GetUserDetail', (req, res, ctx) => { - return res( - ctx.data({ + graphql.query('GetUserDetail', () => { + return HttpResponse.json({ + data: { user: { firstName: 'John', lastName: 'Maverick', }, - }), - ) + }, + }) }), ) 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 2f7110896..5db10c3bc 100644 --- a/test/browser/graphql-api/response-patching.mocks.ts +++ b/test/browser/graphql-api/response-patching.mocks.ts @@ -1,5 +1,6 @@ -import { setupWorker, graphql } from 'msw' -// import { createGraphQLClient, gql } from '../../support/graphql' +import { graphql, bypass, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' +import { createGraphQLClient, gql } from '../../support/graphql' interface GetUserQuery { user: { @@ -9,19 +10,19 @@ interface GetUserQuery { } const worker = setupWorker( - graphql.query('GetUser', async (req, res, ctx) => { - const originalResponse = await ctx.fetch(req) + graphql.query('GetUser', async ({ request }) => { + const originalResponse = await fetch(bypass(request)) const originalJson = await originalResponse.json() - return res( - ctx.data({ + return HttpResponse.json({ + data: { user: { firstName: 'Christian', lastName: originalJson.data?.user?.lastName, }, - }), - ctx.errors(originalJson.errors), - ) + }, + errors: originalJson.errors, + }) }), ) @@ -31,17 +32,17 @@ window.msw = { } // @ts-ignore -// window.dispatchGraphQLQuery = (uri: string) => { -// const client = createGraphQLClient({ uri }) +window.dispatchGraphQLQuery = (uri: string) => { + const client = createGraphQLClient({ uri }) -// return client({ -// query: gql` -// query GetUser { -// user { -// firstName -// lastName -// } -// } -// `, -// }) -// } + return client({ + query: gql` + query GetUser { + user { + firstName + lastName + } + } + `, + }) +} diff --git a/test/browser/graphql-api/response-patching.test.ts b/test/browser/graphql-api/response-patching.test.ts index f2994a4d3..3a175d37d 100644 --- a/test/browser/graphql-api/response-patching.test.ts +++ b/test/browser/graphql-api/response-patching.test.ts @@ -1,6 +1,6 @@ import type { ExecutionResult } from 'graphql' import { buildSchema, graphql } from 'graphql' -import { SetupWorkerApi } from 'msw' +import { SetupWorkerApi } from 'msw/browser' import { HttpServer } from '@open-draft/test-server/http' import { test, expect } from '../playwright.extend' import { gql } from '../../support/graphql' @@ -49,7 +49,7 @@ test.afterEach(async () => { await httpServer.close() }) -test('patches a GraphQL response', async ({ loadExample, page, query }) => { +test('patches a GraphQL response', async ({ loadExample, page }) => { await loadExample(require.resolve('./response-patching.mocks.ts')) const endpointUrl = httpServer.http.url('/graphql') @@ -57,24 +57,16 @@ test('patches a GraphQL response', async ({ loadExample, page, query }) => { return window.msw.registration }) - const res = await query(endpointUrl, { - query: gql` - query GetUser { - user { - firstName - lastName - } - } - `, - }) - const body = await res.json() - - expect(body).toEqual({ - data: { - user: { - firstName: 'Christian', - lastName: 'Maverick', - }, + const res = await page.evaluate( + ([url]) => { + return window.dispatchGraphQLQuery(url) }, + [endpointUrl], + ) + + expect(res.errors).toBeUndefined() + expect(res.data).toHaveProperty('user', { + firstName: 'Christian', + lastName: 'Maverick', }) }) diff --git a/test/browser/graphql-api/variables.mocks.ts b/test/browser/graphql-api/variables.mocks.ts index 9e7c2fc4a..ad13674f6 100644 --- a/test/browser/graphql-api/variables.mocks.ts +++ b/test/browser/graphql-api/variables.mocks.ts @@ -1,4 +1,5 @@ -import { setupWorker, graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' interface GetGitHubUserQuery { user: { @@ -28,55 +29,55 @@ interface GetActiveUserQuery { } interface GetActiveUserQueryVariables { - foo: string + userId: string } const worker = setupWorker( graphql.query( 'GetGithubUser', - (req, res, ctx) => { - const { username } = req.variables + ({ variables }) => { + const { username } = variables - return res( - ctx.data({ + return HttpResponse.json({ + data: { user: { username, firstName: 'John', }, - }), - ) + }, + }) }, ), graphql.mutation( 'DeletePost', - (req, res, ctx) => { - const { postId } = req.variables + ({ variables }) => { + const { postId } = variables - return res( - ctx.data({ + return HttpResponse.json({ + data: { deletePost: { postId, }, - }), - ) + }, + }) }, ), graphql.query( 'GetActiveUser', - (req, res, ctx) => { + ({ variables }) => { // Intentionally unused variable // eslint-disable-next-line - const { foo } = req.variables + const { userId } = variables - return res( - ctx.data({ + return HttpResponse.json({ + data: { user: { id: 1, }, - }), - ) + }, + }) }, ), ) diff --git a/test/browser/msw-api/context/async-response-transformer.mocks.ts b/test/browser/msw-api/context/async-response-transformer.mocks.ts deleted file mode 100644 index 6b45f2720..000000000 --- a/test/browser/msw-api/context/async-response-transformer.mocks.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - ResponseTransformer, - setupWorker, - rest, - context, - compose, - createResponseComposition, -} from 'msw' -import base64Image from 'url-loader!../../../fixtures/image.jpg' - -async function jpeg(base64: string): Promise { - const buffer = await fetch(base64).then((res) => res.arrayBuffer()) - - return compose( - context.set('Content-Length', buffer.byteLength.toString()), - context.set('Content-Type', 'image/jpeg'), - context.body(buffer), - ) -} - -const customResponse = createResponseComposition(null, [ - async (res) => { - res.statusText = 'Custom Status Text' - return res - }, - async (res) => { - res.headers.set('x-custom', 'yes') - return res - }, -]) - -const worker = setupWorker( - rest.get('/image', async (req, res, ctx) => { - return res(ctx.status(201), await jpeg(base64Image)) - }), - rest.post('/search', (req, res, ctx) => { - return customResponse(ctx.status(301)) - }), -) - -worker.start() diff --git a/test/browser/msw-api/context/async-response-transformer.test.ts b/test/browser/msw-api/context/async-response-transformer.test.ts deleted file mode 100644 index f2c9145e8..000000000 --- a/test/browser/msw-api/context/async-response-transformer.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' -import { test, expect } from '../../playwright.extend' - -test('supports asynchronous response transformer', async ({ - loadExample, - fetch, -}) => { - await loadExample(require.resolve('./async-response-transformer.mocks.ts')) - - const res = await fetch('/image') - const body = await res.body() - const expectedBuffer = fs.readFileSync( - path.resolve(__dirname, '../../../fixtures/image.jpg'), - ) - const status = res.status() - const headers = await res.allHeaders() - - expect(status).toBe(201) - expect(headers).toHaveProperty('content-type', 'image/jpeg') - expect(headers).toHaveProperty( - 'content-length', - expectedBuffer.byteLength.toString(), - ) - expect(new Uint8Array(body)).toEqual(new Uint8Array(expectedBuffer)) -}) - -test('supports asynchronous default response transformer', async ({ - loadExample, - fetch, -}) => { - await loadExample(require.resolve('./async-response-transformer.mocks.ts')) - - const res = await fetch('/search', { - method: 'POST', - }) - const status = res.status() - const statusText = res.statusText() - const headers = await res.allHeaders() - - expect(status).toBe(301) - expect(statusText).toBe('Custom Status Text') - expect(headers).toHaveProperty('x-custom', 'yes') -}) diff --git a/test/browser/msw-api/context/delay.mocks.ts b/test/browser/msw-api/context/delay.mocks.ts index 8207fa8e8..f8282b54f 100644 --- a/test/browser/msw-api/context/delay.mocks.ts +++ b/test/browser/msw-api/context/delay.mocks.ts @@ -1,13 +1,15 @@ -import { setupWorker, rest, DelayMode } from 'msw' +import { http, delay, DelayMode, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.get('/delay', (req, res, ctx) => { - const mode = req.url.searchParams.get('mode') as DelayMode - const duration = req.url.searchParams.get('duration') - return res( - ctx.delay(duration ? Number(duration) : mode || undefined), - ctx.json({ mocked: true }), - ) + http.get('/delay', async ({ request }) => { + const url = new URL(request.url) + const mode = url.searchParams.get('mode') as DelayMode + const duration = url.searchParams.get('duration') + + await delay(duration ? Number(duration) : mode || undefined) + + return HttpResponse.json({ mocked: true }) }), ) diff --git a/test/browser/msw-api/context/delay.test.ts b/test/browser/msw-api/context/delay.test.ts index 3a07d55dd..d4b6031ac 100644 --- a/test/browser/msw-api/context/delay.test.ts +++ b/test/browser/msw-api/context/delay.test.ts @@ -44,10 +44,9 @@ test('uses explicit server response delay', async ({ loadExample, fetch }) => { expect(timing.responseStart).toRoughlyEqual(1200, 250) const status = res.status() - const headers = await res.allHeaders() const body = await res.json() - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(status).toBe(200) expect(body).toEqual({ mocked: true }) }) @@ -64,10 +63,9 @@ test('uses realistic server response delay when no delay value is provided', asy expect(timing.responseStart).toRoughlyEqual(250, 300) const status = res.status() - const headers = await res.allHeaders() const body = await res.json() - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(status).toBe(200) expect(body).toEqual({ mocked: true, @@ -85,10 +83,9 @@ test('uses realistic server response delay when "real" delay mode is provided', expect(timing.responseStart).toRoughlyEqual(250, 300) const status = res.status() - const headers = await res.allHeaders() const body = await res.json() - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(status).toBe(200) expect(body).toEqual({ mocked: true, diff --git a/test/browser/msw-api/distribution/iife.mocks.js b/test/browser/msw-api/distribution/iife.mocks.js index e321ecf1a..60d5c1c2c 100644 --- a/test/browser/msw-api/distribution/iife.mocks.js +++ b/test/browser/msw-api/distribution/iife.mocks.js @@ -1,8 +1,8 @@ -const { setupWorker, rest } = MockServiceWorker +const { setupWorker, http, HttpResponse } = MockServiceWorker const worker = setupWorker( - rest.get('/user', (req, res, ctx) => { - return res(ctx.json({ firstName: 'John' })) + http.get('/user', () => { + return HttpResponse.json({ firstName: 'John' }) }), ) diff --git a/test/browser/msw-api/distribution/iife.test.ts b/test/browser/msw-api/distribution/iife.test.ts index 12974d0fa..400c8c492 100644 --- a/test/browser/msw-api/distribution/iife.test.ts +++ b/test/browser/msw-api/distribution/iife.test.ts @@ -27,7 +27,6 @@ test('supports the usage of the iife bundle in a + fetch('/formData', { + method: 'POST', + body: data, + }) + } + + + diff --git a/test/browser/rest-api/request/body/body-form-data.test.ts b/test/browser/rest-api/request/body/body-form-data.test.ts index 704d8bff9..2cb9e899c 100644 --- a/test/browser/rest-api/request/body/body-form-data.test.ts +++ b/test/browser/rest-api/request/body/body-form-data.test.ts @@ -1,5 +1,11 @@ import { test, expect } from '../../../playwright.extend' +declare global { + interface Window { + makeRequest(): void + } +} + test('handles FormData as a request body', async ({ loadExample, page, @@ -9,15 +15,18 @@ test('handles FormData as a request body', async ({ markup: require.resolve('./body-form-data.page.html'), }) - page.click('button') + await page.evaluate(() => window.makeRequest()) + + await page.pause() - const res = await page.waitForResponse(makeUrl('/deprecated')) + const res = await page.waitForResponse(makeUrl('/formData')) const status = res.status() const json = await res.json() expect(status).toBe(200) expect(json).toEqual({ - username: 'john.maverick', - password: 'secret123', + name: 'Alice', + file: 'hello world', + ids: [1, 2, 3], }) }) diff --git a/test/browser/rest-api/request/body/body-json.test.ts b/test/browser/rest-api/request/body/body-json.test.ts index de3cadc28..09a6e98b0 100644 --- a/test/browser/rest-api/request/body/body-json.test.ts +++ b/test/browser/rest-api/request/body/body-json.test.ts @@ -3,7 +3,7 @@ import { test, expect } from '../../../playwright.extend' test('reads request body as json', async ({ loadExample, fetch, page }) => { await loadExample(require.resolve('./body.mocks.ts')) - const res = await fetch('/deprecated', { + const res = await fetch('/json', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -24,7 +24,7 @@ test('reads a single number as json request body', async ({ }) => { await loadExample(require.resolve('./body.mocks.ts')) - const res = await fetch('/deprecated', { + const res = await fetch('/json', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/test/browser/rest-api/request/body/body.mocks.ts b/test/browser/rest-api/request/body/body.mocks.ts index b2d052c6b..c03ab78b0 100644 --- a/test/browser/rest-api/request/body/body.mocks.ts +++ b/test/browser/rest-api/request/body/body.mocks.ts @@ -1,20 +1,29 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.post('/deprecated', (req, res, ctx) => { - return res(ctx.json(req.body)) + http.post('/text', async ({ request }) => { + return HttpResponse.text(await request.text()) }), - rest.post('/text', async (req, res, ctx) => { - const body = await req.text() - return res(ctx.body(body)) + http.post('/json', async ({ request }) => { + return HttpResponse.json(await request.json()) }), - rest.post('/json', async (req, res, ctx) => { - const json = await req.json() - return res(ctx.json(json)) + http.post('/arrayBuffer', async ({ request }) => { + return HttpResponse.arrayBuffer(await request.arrayBuffer()) }), - rest.post('/arrayBuffer', async (req, res, ctx) => { - const arrayBuffer = await req.arrayBuffer() - return res(ctx.body(arrayBuffer)) + http.post('/formData', async ({ request }) => { + const data = await request.formData() + const name = data.get('name') + const file = data.get('file') as File + const fileText = await file.text() + const ids = data.get('ids') as File + const idsJson = JSON.parse(await ids.text()) + + return HttpResponse.json({ + name, + file: fileText, + ids: idsJson, + }) }), ) diff --git a/test/browser/rest-api/request/matching/all.mocks.ts b/test/browser/rest-api/request/matching/all.mocks.ts index d606dcfa0..bab7e136a 100644 --- a/test/browser/rest-api/request/matching/all.mocks.ts +++ b/test/browser/rest-api/request/matching/all.mocks.ts @@ -1,11 +1,12 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.all('*/api/*', (req, res, ctx) => { - return res(ctx.text('hello world')) + http.all('*/api/*', () => { + return HttpResponse.text('hello world') }), - rest.all('*', (req, res, ctx) => { - return res(ctx.text('welcome to the jungle')) + http.all('*', () => { + return HttpResponse.text('welcome to the jungle') }), ) diff --git a/test/browser/rest-api/request/matching/all.test.ts b/test/browser/rest-api/request/matching/all.test.ts index 074e3197e..ab23ef155 100644 --- a/test/browser/rest-api/request/matching/all.test.ts +++ b/test/browser/rest-api/request/matching/all.test.ts @@ -41,7 +41,7 @@ test('respects custom path when matching requests', async ({ } // Mismatched request. - // There's a fallback "rest.all()" in this test that acts + // There's a fallback "http.all()" in this test that acts // as a fallback request handler for all otherwise mismatched requests. const mismatchedResponses = await forEachMethod((method) => { return fetch('http://localhost/foo', { method }) diff --git a/test/browser/rest-api/request/matching/method.mocks.ts b/test/browser/rest-api/request/matching/method.mocks.ts index 0bc778e58..08fd0ba16 100644 --- a/test/browser/rest-api/request/matching/method.mocks.ts +++ b/test/browser/rest-api/request/matching/method.mocks.ts @@ -1,12 +1,9 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.post('*/user', (req, res, ctx) => { - return res( - ctx.json({ - mocked: true, - }), - ) + http.post('*/user', () => { + return HttpResponse.json({ mocked: true }) }), ) diff --git a/test/browser/rest-api/request/matching/method.test.ts b/test/browser/rest-api/request/matching/method.test.ts index 407bd080c..e0b074a8b 100644 --- a/test/browser/rest-api/request/matching/method.test.ts +++ b/test/browser/rest-api/request/matching/method.test.ts @@ -29,11 +29,10 @@ test('sends a mocked response to a matching method and url', async ({ method: 'POST', }) const status = res.status() - const headers = await res.allHeaders() const body = await res.json() expect(status).toBe(200) - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(body).toEqual({ mocked: true, }) diff --git a/test/browser/rest-api/request/matching/path-params-decode.mocks.ts b/test/browser/rest-api/request/matching/path-params-decode.mocks.ts index 60ff634cf..98e71c479 100644 --- a/test/browser/rest-api/request/matching/path-params-decode.mocks.ts +++ b/test/browser/rest-api/request/matching/path-params-decode.mocks.ts @@ -1,10 +1,10 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.get('https://test.mswjs.io/reflect-url/:url', (req, res, ctx) => { - const { url } = req.params - - return res(ctx.json({ url })) + http.get('https://test.mswjs.io/reflect-url/:url', ({ params }) => { + const { url } = params + return HttpResponse.json({ url }) }), ) diff --git a/test/browser/rest-api/request/matching/path-params-decode.test.ts b/test/browser/rest-api/request/matching/path-params-decode.test.ts index 2d02e15bd..02ee458db 100644 --- a/test/browser/rest-api/request/matching/path-params-decode.test.ts +++ b/test/browser/rest-api/request/matching/path-params-decode.test.ts @@ -9,7 +9,7 @@ test('decodes url componets', async ({ loadExample, fetch }) => { ) expect(res.status()).toBe(200) - expect(await res.allHeaders()).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(await res.json()).toEqual({ url, }) diff --git a/test/browser/rest-api/request/matching/uri.mocks.ts b/test/browser/rest-api/request/matching/uri.mocks.ts index 7beccfc73..38262dde7 100644 --- a/test/browser/rest-api/request/matching/uri.mocks.ts +++ b/test/browser/rest-api/request/matching/uri.mocks.ts @@ -1,43 +1,27 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.get('https://api.github.com/made-up', (req, res, ctx) => { - return res( - ctx.json({ - mocked: true, - }), - ) + http.get('https://api.github.com/made-up', () => { + return HttpResponse.json({ mocked: true }) }), - rest.get('https://test.mswjs.io/messages/:messageId', (req, res, ctx) => { - const { messageId } = req.params - - return res( - ctx.json({ - messageId, - }), - ) + http.get('https://test.mswjs.io/messages/:messageId', ({ params }) => { + const { messageId } = params + return HttpResponse.json({ messageId }) }), - rest.get( - 'https://test.mswjs.io/messages/:messageId/items', - (req, res, ctx) => { - const { messageId } = req.params - - return res( - ctx.json({ - messageId, - }), - ) - }, - ), + http.get('https://test.mswjs.io/messages/:messageId/items', ({ params }) => { + const { messageId } = params + return HttpResponse.json({ messageId }) + }), - rest.get(/(.+?)\.google\.com\/path/, (req, res, ctx) => { - return res(ctx.json({ mocked: true })) + http.get(/(.+?)\.google\.com\/path/, () => { + return HttpResponse.json({ mocked: true }) }), - rest.get(`/resource\\('id'\\)`, (req, res, ctx) => { - return res(ctx.json({ mocked: true })) + http.get(`/resource\\('id'\\)`, () => { + return HttpResponse.json({ mocked: true }) }), ) diff --git a/test/browser/rest-api/request/matching/uri.test.ts b/test/browser/rest-api/request/matching/uri.test.ts index 362e44c57..3029fe2fb 100644 --- a/test/browser/rest-api/request/matching/uri.test.ts +++ b/test/browser/rest-api/request/matching/uri.test.ts @@ -9,7 +9,7 @@ test('matches an exact string with the same request URL with a trailing slash', const res = await fetch('https://api.github.com/made-up/') expect(res.status()).toEqual(200) - expect(await res.allHeaders()).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(await res.json()).toEqual({ mocked: true, }) @@ -36,7 +36,7 @@ test('matches an exact string with the same request URL without a trailing slash const res = await fetch('https://api.github.com/made-up') expect(res.status()).toEqual(200) - expect(await res.allHeaders()).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(await res.json()).toEqual({ mocked: true, }) @@ -63,7 +63,7 @@ test('matches a mask against a matching request URL', async ({ const res = await fetch('https://test.mswjs.io/messages/abc-123') expect(res.status()).toEqual(200) - expect(await res.allHeaders()).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(await res.json()).toEqual({ messageId: 'abc-123', }) @@ -80,7 +80,7 @@ test('ignores query parameters when matching a mask against a matching request U ) expect(res.status()).toEqual(200) - expect(await res.allHeaders()).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(await res.json()).toEqual({ messageId: 'abc-123', }) @@ -109,7 +109,7 @@ test('matches a RegExp against a matching request URL', async ({ const res = await fetch('https://mswjs.google.com/path') expect(res.status()).toEqual(200) - expect(await res.allHeaders()).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(await res.json()).toEqual({ mocked: true, }) @@ -138,7 +138,7 @@ test('supports escaped parentheses in the request URL', async ({ const res = await fetch(`/resource('id')`) expect(res.status()).toEqual(200) - expect(await res.allHeaders()).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(await res.json()).toEqual({ mocked: true, }) diff --git a/test/browser/rest-api/response-patching.mocks.ts b/test/browser/rest-api/response-patching.mocks.ts index 87c79eeff..1ca6bccfb 100644 --- a/test/browser/rest-api/response-patching.mocks.ts +++ b/test/browser/rest-api/response-patching.mocks.ts @@ -1,77 +1,108 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse, bypass } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.get('*/user', async (req, res, ctx) => { - /** - * @note Do not pass the entire "req" as the input to "ctx.fetch" - * because then the bypassed request will inherit the "Accept-Language" header - * that's used by tests to await for responses. It will await the incorrect - * response in that case. - */ - const originalResponse = await ctx.fetch(req.url.href) + http.get('*/user', async ({ request }) => { + const originalResponse = await fetch(bypass(request.url)) const body = await originalResponse.json() - return res( - ctx.json({ + return HttpResponse.json( + { name: body.name, location: body.location, mocked: true, - }), + }, + { + headers: { + 'X-Source': 'msw', + }, + }, ) }), - rest.get('*/repos/:owner/:repoName', async (req, res, ctx) => { - const originalResponse = await ctx.fetch(req.url.href) + http.get('*/repos/:owner/:repoName', async ({ request }) => { + const originalResponse = await fetch(bypass(request)) const body = await originalResponse.json() - return res( - ctx.json({ + return HttpResponse.json( + { name: body.name, stargazers_count: 9999, - }), + }, + { + headers: { + 'X-Source': 'msw', + }, + }, ) }), - rest.get('*/headers', async (req, res, ctx) => { - const proxyUrl = new URL('/headers-proxy', req.url).href - const originalResponse = await ctx.fetch(proxyUrl, { - method: 'POST', - headers: req.headers.all(), - }) + http.get('*/headers', async ({ request }) => { + const proxyUrl = new URL('/headers-proxy', request.url) + const originalResponse = await fetch( + bypass(proxyUrl, { + method: 'POST', + headers: request.headers, + }), + ) const body = await originalResponse.json() - return res(ctx.json(body)) + return HttpResponse.json(body, { + headers: { + 'X-Source': 'msw', + }, + }) }), - rest.post('*/posts', async (req, res, ctx) => { - const originalResponse = await ctx.fetch(req) + http.post('*/posts', async ({ request }) => { + const originalResponse = await fetch(bypass(request)) const body = await originalResponse.json() - return res( - ctx.set('x-custom', originalResponse.headers.get('x-custom')!), - ctx.json({ + return HttpResponse.json( + { ...body, mocked: true, - }), + }, + { + headers: { + 'X-Source': 'msw', + 'X-Custom': originalResponse.headers.get('x-custom') || '', + }, + }, ) }), - rest.get('*/posts', async (req, res, ctx) => { - const originalResponse = await ctx.fetch(req.url.href) + http.get('*/posts', async ({ request }) => { + const originalResponse = await fetch(bypass(request)) const body = await originalResponse.json() - return res( - ctx.json({ + return HttpResponse.json( + { ...body, mocked: true, - }), + }, + { + headers: { + 'X-Source': 'msw', + }, + }, ) }), - rest.head('*/posts', async (req, res, ctx) => { - const originalResponse = await ctx.fetch(req.url.href, { method: 'HEAD' }) + http.head('*/posts', async ({ request }) => { + const originalResponse = await fetch(bypass(request)) - return res(ctx.set('x-custom', originalResponse.headers.get('x-custom')!)) + return HttpResponse.json( + { + mocked: true, + }, + { + headers: { + 'X-Source': 'msw', + 'X-Custom': originalResponse.headers.get('x-custom') || '', + }, + }, + ) }), ) diff --git a/test/browser/rest-api/response-patching.test.ts b/test/browser/rest-api/response-patching.test.ts index 1ad2df148..6ee5d5b48 100644 --- a/test/browser/rest-api/response-patching.test.ts +++ b/test/browser/rest-api/response-patching.test.ts @@ -63,11 +63,10 @@ test('responds with a combination of the mocked and original responses', async ( const res = await fetch(httpServer.http.url('/user')) const status = res.status() - const headers = await res.allHeaders() const body = await res.json() expect(status).toBe(200) - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(body).toEqual({ name: 'The Octocat', location: 'San Francisco', @@ -90,18 +89,17 @@ test('bypasses the original request when it equals the mocked request', async ({ // Await the response from MSW so that the original response // from the same URL would not interfere. matchRequestUrl(new URL(res.request().url()), res.url()).matches && - res.headers()['x-powered-by'] === 'msw' + res.headers()['x-source'] === 'msw' ) }, }, ) const status = res.status() - const headers = await res.allHeaders() const body = await res.json() expect(status).toBe(200) - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(body).toEqual({ name: 'msw', stargazers_count: 9999, @@ -147,20 +145,19 @@ test('supports patching a HEAD request', async ({ loadExample, fetch }) => { const headers = res.headers() return ( - headers['x-powered-by'] === 'msw' && - headers['x-msw-bypass'] !== 'true' + headers['x-source'] === 'msw' && headers['x-msw-bypass'] !== 'true' ) }, }, ) const status = res.status() - const headers = await res.allHeaders() + const headers = res.headers() expect(status).toBe(200) expect(headers).toEqual( expect.objectContaining({ - 'x-powered-by': 'msw', + 'x-source': 'msw', 'x-custom': 'HEAD REQUEST PATCHED', }), ) @@ -185,17 +182,16 @@ test('supports patching a GET request', async ({ waitForResponse(res) { return ( matchRequestUrl(new URL(makeUrl(res.request().url())), res.url()) - .matches && res.headers()['x-powered-by'] === 'msw' + .matches && res.headers()['x-source'] === 'msw' ) }, }, ) const status = res.status() - const headers = await res.allHeaders() const body = await res.json() expect(status).toBe(200) - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(body).toEqual({ id: 101, mocked: true }) }) @@ -203,7 +199,6 @@ test('supports patching a POST request', async ({ loadExample, fetch, makeUrl, - page, }) => { await loadExample(require.resolve('./response-patching.mocks.ts')) @@ -224,19 +219,17 @@ test('supports patching a POST request', async ({ waitForResponse(res) { return ( matchRequestUrl(new URL(makeUrl(res.request().url())), res.url()) - .matches && res.headers()['x-powered-by'] === 'msw' + .matches && res.headers()['x-source'] === 'msw' ) }, }, ) const status = res.status() - const headers = await res.allHeaders() + const headers = res.headers() const body = await res.json() - await page.pause() - expect(status).toBe(200) - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(headers).toHaveProperty('x-custom', 'POST REQUEST PATCHED') expect(body).toEqual({ id: 101, diff --git a/test/browser/rest-api/response/body/body-binary.mocks.ts b/test/browser/rest-api/response/body/body-binary.mocks.ts index 78459d7ab..751f7e9ec 100644 --- a/test/browser/rest-api/response/body/body-binary.mocks.ts +++ b/test/browser/rest-api/response/body/body-binary.mocks.ts @@ -1,17 +1,18 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' import base64Image from 'url-loader!../../../../fixtures/image.jpg' const worker = setupWorker( - rest.get('/images/:imageId', async (_, res, ctx) => { + http.get('/images/:imageId', async () => { const imageBuffer = await fetch(base64Image).then((res) => res.arrayBuffer(), ) - return res( - ctx.set('Content-Length', imageBuffer.byteLength.toString()), - ctx.set('Content-Type', 'image/jpeg'), - ctx.body(imageBuffer), - ) + return HttpResponse.arrayBuffer(imageBuffer, { + headers: { + 'Content-Type': 'image/jpeg', + }, + }) }), ) diff --git a/test/browser/rest-api/response/body/body-binary.test.ts b/test/browser/rest-api/response/body/body-binary.test.ts index b173104c1..186a98f0a 100644 --- a/test/browser/rest-api/response/body/body-binary.test.ts +++ b/test/browser/rest-api/response/body/body-binary.test.ts @@ -7,7 +7,6 @@ test('responds with a given binary body', async ({ loadExample, fetch }) => { const res = await fetch('/images/abc-123') const status = res.status() - const headers = await res.allHeaders() const body = await res.body() const expectedBuffer = fs.readFileSync( @@ -15,6 +14,6 @@ test('responds with a given binary body', async ({ loadExample, fetch }) => { ) expect(status).toBe(200) - expect(headers).toHaveProperty('x-powered-by', 'msw') + expect(res.fromServiceWorker()).toBe(true) expect(new Uint8Array(body)).toEqual(new Uint8Array(expectedBuffer)) }) diff --git a/test/browser/rest-api/response/body/body-blob.mocks.ts b/test/browser/rest-api/response/body/body-blob.mocks.ts new file mode 100644 index 000000000..eb7b26901 --- /dev/null +++ b/test/browser/rest-api/response/body/body-blob.mocks.ts @@ -0,0 +1,14 @@ +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker( + http.get('/greeting', async () => { + const blob = new Blob(['hello world'], { + type: 'text/plain', + }) + + return new HttpResponse(blob) + }), +) + +worker.start() diff --git a/test/browser/rest-api/response/body/body-blob.test.ts b/test/browser/rest-api/response/body/body-blob.test.ts new file mode 100644 index 000000000..a6b655184 --- /dev/null +++ b/test/browser/rest-api/response/body/body-blob.test.ts @@ -0,0 +1,13 @@ +import { test, expect } from '../.././../playwright.extend' + +test('responds to a request with a Blob', async ({ loadExample, fetch }) => { + await loadExample(require.resolve('./body-blob.mocks.ts')) + const res = await fetch('/greeting') + + const headers = await res.allHeaders() + expect(headers).toHaveProperty('content-type', 'text/plain') + expect(res.fromServiceWorker()).toBe(true) + + const text = await res.text() + expect(text).toBe('hello world') +}) diff --git a/test/browser/rest-api/response/body/body-formdata.mocks.ts b/test/browser/rest-api/response/body/body-formdata.mocks.ts new file mode 100644 index 000000000..29cf1c27c --- /dev/null +++ b/test/browser/rest-api/response/body/body-formdata.mocks.ts @@ -0,0 +1,14 @@ +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker( + http.get('/user', async () => { + const data = new FormData() + data.append('name', 'Alice') + data.append('age', '32') + + return HttpResponse.formData(data) + }), +) + +worker.start() diff --git a/test/browser/rest-api/response/body/body-formdata.test.ts b/test/browser/rest-api/response/body/body-formdata.test.ts new file mode 100644 index 000000000..e4eb625ca --- /dev/null +++ b/test/browser/rest-api/response/body/body-formdata.test.ts @@ -0,0 +1,18 @@ +import { test, expect } from '../.././../playwright.extend' + +test('responds to a request with FormData', async ({ loadExample, fetch }) => { + await loadExample(require.resolve('./body-formdata.mocks.ts')) + const res = await fetch('/user') + + const headers = await res.allHeaders() + expect(headers).toHaveProperty( + 'content-type', + expect.stringContaining('multipart/form-data'), + ) + expect(res.fromServiceWorker()).toBe(true) + + const text = await res.text() + expect(text).toMatch( + /------WebKitFormBoundary.+?\r\nContent-Disposition: form-data; name="name"\r\n\r\nAlice\r\n------WebKitFormBoundary.+?\r\nContent-Disposition: form-data; name="age"\r\n\r\n32\r\n------WebKitFormBoundary.+?--/gm, + ) +}) diff --git a/test/browser/rest-api/response/body/body-json.mocks.ts b/test/browser/rest-api/response/body/body-json.mocks.ts index 44357b41f..7bf637cc2 100644 --- a/test/browser/rest-api/response/body/body-json.mocks.ts +++ b/test/browser/rest-api/response/body/body-json.mocks.ts @@ -1,11 +1,12 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.get('/json', (req, res, ctx) => { - return res(ctx.json({ firstName: 'John' })) + http.get('/json', () => { + return HttpResponse.json({ firstName: 'John' }) }), - rest.get('/number', (req, res, ctx) => { - return res(ctx.json(123)) + http.get('/number', () => { + return HttpResponse.json(123) }), ) 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/browser/rest-api/response/body/body-text.mocks.ts b/test/browser/rest-api/response/body/body-text.mocks.ts index 6ffc86a45..fbac4101d 100644 --- a/test/browser/rest-api/response/body/body-text.mocks.ts +++ b/test/browser/rest-api/response/body/body-text.mocks.ts @@ -1,8 +1,9 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.get('/text', (req, res, ctx) => { - return res(ctx.text('hello world')) + http.get('/text', () => { + return HttpResponse.text('hello world') }), ) diff --git a/test/browser/rest-api/response/body/body-xml.mocks.ts b/test/browser/rest-api/response/body/body-xml.mocks.ts index 37cf076bf..e58c1bd63 100644 --- a/test/browser/rest-api/response/body/body-xml.mocks.ts +++ b/test/browser/rest-api/response/body/body-xml.mocks.ts @@ -1,15 +1,14 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.get('/user', (req, res, ctx) => { - return res( - ctx.xml(` + http.get('/user', () => { + return HttpResponse.xml(` abc-123 John Maverick -`), - ) +`) }), ) diff --git a/test/browser/rest-api/response/response-error.mocks.ts b/test/browser/rest-api/response/response-error.mocks.ts new file mode 100644 index 000000000..c372dda35 --- /dev/null +++ b/test/browser/rest-api/response/response-error.mocks.ts @@ -0,0 +1,10 @@ +import { http } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker( + http.get('/resource', () => { + return Response.error() + }), +) + +worker.start() diff --git a/test/browser/rest-api/response/response-error.test.ts b/test/browser/rest-api/response/response-error.test.ts new file mode 100644 index 000000000..a05b4163f --- /dev/null +++ b/test/browser/rest-api/response/response-error.test.ts @@ -0,0 +1,27 @@ +import { test, expect } from '../../playwright.extend' + +test('responds with a mocked error response using "Response.error" shorthand', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./response-error.mocks.ts')) + + const responseError = await page.evaluate(() => { + return fetch('/resource') + .then(() => null) + .catch((error) => ({ + name: error.name, + message: error.message, + stack: error.stack, + cause: error.cause, + })) + }) + + // Responding with a "Response.error()" produced a "Failed to fetch" error, + // breaking the request. This is analogous to a network error. + expect(responseError?.name).toBe('TypeError') + expect(responseError?.message).toBe('Failed to fetch') + // Guard against false positives due to exceptions arising from the library. + expect(responseError?.cause).toBeUndefined() +}) diff --git a/test/browser/rest-api/status.mocks.ts b/test/browser/rest-api/status.mocks.ts index fb138b25a..44902088f 100644 --- a/test/browser/rest-api/status.mocks.ts +++ b/test/browser/rest-api/status.mocks.ts @@ -1,15 +1,19 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.get('/posts', (req, res, ctx) => { + http.get('/posts', () => { // Setting response status code without status text // implicitly sets the correct status text. - return res(ctx.status(403)) + return HttpResponse.text(null, { status: 403 }) }), - rest.get('/user', (req, res, ctx) => { + http.get('/user', () => { // Response status text can be overridden // to an arbitrary string value. - return res(ctx.status(401, 'Custom text')) + return HttpResponse.text(null, { + status: 401, + statusText: 'Custom text', + }) }), ) diff --git a/test/browser/rest-api/xhr.mocks.ts b/test/browser/rest-api/xhr.mocks.ts index 42dae4379..28239554b 100644 --- a/test/browser/rest-api/xhr.mocks.ts +++ b/test/browser/rest-api/xhr.mocks.ts @@ -1,8 +1,9 @@ -import { setupWorker, rest } from 'msw' +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' const worker = setupWorker( - rest.get('https://api.github.com/users/octocat', (req, res, ctx) => { - return res(ctx.json({ mocked: true })) + http.get('https://api.github.com/users/octocat', () => { + return HttpResponse.json({ mocked: true }) }), ) diff --git a/test/browser/setup/webpackHttpServer.ts b/test/browser/setup/webpackHttpServer.ts index bfed60ae0..e24a07a4e 100644 --- a/test/browser/setup/webpackHttpServer.ts +++ b/test/browser/setup/webpackHttpServer.ts @@ -1,7 +1,7 @@ import * as fs from 'fs' import * as path from 'path' import { WebpackHttpServer } from 'webpack-http-server' -import { getWorkerScriptPatch } from './WorkerConsole' +import { getWorkerScriptPatch } from './workerConsole' const { SERVICE_WORKER_BUILD_PATH } = require('../../../config/constants.js') @@ -62,7 +62,7 @@ export async function startWebpackServer(): Promise { alias: { msw: path.resolve(__dirname, '../../..'), }, - extensions: ['.ts', '.js'], + extensions: ['.ts', '.js', '.mjs'], }, }, }) diff --git a/test/jest.config.js b/test/jest.config.js index 4f865b000..1f8692b34 100644 --- a/test/jest.config.js +++ b/test/jest.config.js @@ -1,4 +1,4 @@ -/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +/** @type {import('jest').Config} */ module.exports = { rootDir: './node', transform: { @@ -10,4 +10,20 @@ module.exports = { moduleNameMapper: { '^msw(.*)': '/../..$1', }, + 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 + // and doesn't ship with 100% compatibility with the browser APIs. + // In tests, using browser resolution will result in "ClientRequest" imports + // from "@mswjs/interceptors" to not be found because they are not exported + // by the browser bundle of that library. + customExportConditions: [''], + }, + globals: { + fetch, + Request, + Response, + TextEncoder, + TextDecoder, + }, } diff --git a/test/modules/browser/esm-browser.test.ts b/test/modules/browser/esm-browser.test.ts new file mode 100644 index 000000000..53bfbe2eb --- /dev/null +++ b/test/modules/browser/esm-browser.test.ts @@ -0,0 +1,87 @@ +import * as path from 'path' +import { invariant } from 'outvariant' +import { createTeardown } from 'fs-teardown' +import * as express from 'express' +import { HttpServer } from '@open-draft/test-server/http' +import { test, expect } from '@playwright/test' +import { spyOnConsole } from 'page-with' +import { startDevServer } from '@web/dev-server' +import { installLibrary } from '../module-utils' + +type DevServer = Awaited> + +const fsMock = createTeardown({ + rootDir: path.resolve(__dirname, 'esm-browser-tests'), + paths: { + 'package.json': JSON.stringify({ type: 'module' }), + }, +}) + +let devServer: DevServer + +function getDevServerUrl(): string { + const address = devServer.server.address() + + invariant(address, 'Failed to retrieve dev server url: null') + + if (typeof address === 'string') { + return new URL(address).href + } + + return new URL(`http://localhost:${address.port}`).href +} + +const httpServer = new HttpServer((app) => { + app.use(express.static(fsMock.resolve('.'))) +}) + +test.beforeAll(async () => { + devServer = await startDevServer({ + config: { + rootDir: fsMock.resolve('.'), + port: 0, + nodeResolve: { + exportConditions: ['browser'], + }, + }, + logStartMessage: false, + }) + + await httpServer.listen() + await fsMock.prepare() + await installLibrary(fsMock.resolve('.')) +}) + +test.afterAll(async () => { + await devServer?.stop() + await httpServer.close() + await fsMock.cleanup() +}) + +test('runs in an ESM browser project', async ({ page }) => { + await fsMock.create({ + 'entry.mjs': ` +import { http,HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' +const worker = setupWorker( + http.get('/resource', () => new Response()), + http.post('/login', () => HttpResponse.json([1, 2, 3])) +) +console.log(typeof worker.start) + `, + 'index.html': ` + + `, + }) + const consoleSpy = spyOnConsole(page) + const pageErrors: Array = [] + page.on('pageerror', (error) => + pageErrors.push(`${error.message}\n${error.stack}`), + ) + + await page.goto(getDevServerUrl(), { waitUntil: 'networkidle' }) + + expect(pageErrors).toEqual([]) + expect(consoleSpy.get('error')).toBeUndefined() + expect(consoleSpy.get('log')).toEqual(expect.arrayContaining(['function'])) +}) diff --git a/test/modules/browser/playwright.config.ts b/test/modules/browser/playwright.config.ts new file mode 100644 index 000000000..2fea97eca --- /dev/null +++ b/test/modules/browser/playwright.config.ts @@ -0,0 +1,13 @@ +import { Config } from '@playwright/test' + +const config: Config = { + testDir: __dirname, + use: { + launchOptions: { + devtools: !process.env.CI, + }, + }, + fullyParallel: true, +} + +export default config diff --git a/test/modules/module-utils.ts b/test/modules/module-utils.ts new file mode 100644 index 000000000..0ec7752e5 --- /dev/null +++ b/test/modules/module-utils.ts @@ -0,0 +1,52 @@ +import * as fs from 'fs' +import * as path from 'path' +import { spawnSync } from 'child_process' +import { invariant } from 'outvariant' + +export async function getLibraryTarball(): Promise { + const ROOT_PATH = path.resolve(__dirname, '../..') + const { version } = require(`${ROOT_PATH}/package.json`) + const packFilename = `msw-${version}.tgz` + const packPath = path.resolve(ROOT_PATH, packFilename) + + /** + * @note Beware that you need to remove the tarball after + * the test run is done. Don't want to use a stale tgarball, do you? + */ + if (fs.existsSync(packPath)) { + return packPath + } + + const out = spawnSync('pnpm', ['pack'], { cwd: ROOT_PATH }) + + if (out.error) { + console.error(out.error) + } + + invariant( + fs.existsSync(packPath), + 'Failed to pack the library at "%s"', + packPath, + ) + + return packPath +} + +export async function installLibrary(projectPath: string) { + const TARBALL_PATH = await getLibraryTarball() + + const out = spawnSync('pnpm', ['install', TARBALL_PATH], { + cwd: projectPath, + }) + + if (out.error) { + console.error(out.error) + return Promise.reject( + 'Failed to install the library. See the stderr output above.', + ) + } + + /** @todo Assert that pnpm printed success: + * + msw 0.0.0-fetch.rc-11 + */ +} diff --git a/test/modules/node/esm-node.test.ts b/test/modules/node/esm-node.test.ts new file mode 100644 index 000000000..303a19e7f --- /dev/null +++ b/test/modules/node/esm-node.test.ts @@ -0,0 +1,108 @@ +import * as path from 'path' +import { createTeardown } from 'fs-teardown' +import { installLibrary } from '../module-utils' + +const fsMock = createTeardown({ + rootDir: path.resolve(__dirname, 'node-esm-tests'), + paths: { + 'package.json': JSON.stringify({ type: 'module' }), + }, +}) + +beforeAll(async () => { + await fsMock.prepare() + await installLibrary(fsMock.resolve('.')) +}) + +afterAll(async () => { + await fsMock.cleanup() +}) + +it('runs in a ESM Node.js project', async () => { + await fsMock.create({ + 'resolve.mjs': ` +console.log('msw:', await import.meta.resolve('msw')) +console.log('msw/node:', await import.meta.resolve('msw/node')) +console.log('msw/native:', await import.meta.resolve('msw/native')) +`, + 'runtime.mjs': ` +import { http } from 'msw' +import { setupServer } from 'msw/node' +const server = setupServer( + http.get('/resource', () => new Response()) +) +console.log(typeof server.listen) +`, + }) + + const resolveStdio = await fsMock.exec( + /** + * @note Using the import meta resolve flag + * to enable the "import.meta.resolve" API to see + * what library imports resolve to in Node.js ESM. + */ + 'node --experimental-import-meta-resolve ./resolve.mjs', + ) + expect(resolveStdio.stderr).toBe('') + /** + * @todo Take these expected export paths from package.json. + * That should be the source of truth. + */ + expect(resolveStdio.stdout).toMatch( + /^msw: (.+?)\/node_modules\/msw\/lib\/core\/index\.mjs/m, + ) + expect(resolveStdio.stdout).toMatch( + /^msw\/node: (.+?)\/node_modules\/msw\/lib\/node\/index\.mjs/m, + ) + expect(resolveStdio.stdout).toMatch( + /^msw\/native: (.+?)\/node_modules\/msw\/lib\/native\/index\.mjs/m, + ) + + /** + * @todo Also test the "msw/browser" import that throws, + * saying that the "./browser" export is not defined. + * That's correct, it's exlpicitly set as "browser: null" for Node.js. + */ + + const runtimeStdio = await fsMock.exec('node ./runtime.mjs') + expect(runtimeStdio.stderr).toBe('') + expect(runtimeStdio.stdout).toMatch(/function/m) +}) + +it('runs in a CJS Node.js project', async () => { + await fsMock.create({ + 'resolve.cjs': ` +console.log('msw:', require.resolve('msw')) +console.log('msw/node:', require.resolve('msw/node')) +console.log('msw/native:', require.resolve('msw/native')) +`, + 'runtime.cjs': ` +import { http } from 'msw' +import { setupServer } from 'msw/node' +const server = setupServer( + http.get('/resource', () => new Response()) +) +console.log(typeof server.listen) +`, + }) + + const resolveStdio = await fsMock.exec('node ./resolve.cjs') + expect(resolveStdio.stderr).toBe('') + /** + * @todo Take these expected export paths from package.json. + * That should be the source of truth. + */ + expect(resolveStdio.stdout).toMatch( + /^msw: (.+?)\/node_modules\/msw\/lib\/core\/index\.js/m, + ) + expect(resolveStdio.stdout).toMatch( + /^msw\/node: (.+?)\/node_modules\/msw\/lib\/node\/index\.js/m, + ) + expect(resolveStdio.stdout).toMatch( + /^msw\/native: (.+?)\/node_modules\/msw\/lib\/native\/index\.js/m, + ) + + const runtimeStdio = await fsMock.exec('node ./runtime.mjs') + expect(runtimeStdio.stderr).toBe('') + expect(runtimeStdio.stdout).toMatch(/function/m) +}) diff --git a/test/modules/node/jest.config.js b/test/modules/node/jest.config.js new file mode 100644 index 000000000..5d31469cb --- /dev/null +++ b/test/modules/node/jest.config.js @@ -0,0 +1,9 @@ +/** @type {import('jest').Config} */ +module.exports = { + rootDir: '.', + transform: { + '^.+\\.ts$': '@swc/jest', + }, + testEnvironment: 'node', + testTimeout: 60_000, +} diff --git a/test/node/graphql-api/anonymous-operations.test.ts b/test/node/graphql-api/anonymous-operations.test.ts new file mode 100644 index 000000000..8a7643435 --- /dev/null +++ b/test/node/graphql-api/anonymous-operations.test.ts @@ -0,0 +1,108 @@ +/** + * @jest-environment node + */ +import fetch from 'node-fetch' +import { HttpServer } from '@open-draft/test-server/http' +import { HttpResponse, graphql } from 'msw' +import { setupServer } from 'msw/node' + +const httpServer = new HttpServer((app) => { + app.post('/graphql', (req, res) => { + res.json({ + data: { + user: { id: 'abc-123' }, + }, + }) + }) +}) + +const server = setupServer(graphql.query('GetUser', () => {})) + +beforeAll(async () => { + server.listen() + await httpServer.listen() + jest.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + server.resetHandlers() + jest.resetAllMocks() +}) + +afterAll(async () => { + jest.restoreAllMocks() + server.close() + await httpServer.close() +}) + +test('warns on unhandled anonymous GraphQL operations', async () => { + const endpointUrl = httpServer.http.url('/graphql') + const response = await fetch(endpointUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: ` + query { + user { + id + } + } + `, + }), + }) + const json = await response.json() + + // Must receive the original server response. + expect(json).toEqual({ + data: { user: { id: 'abc-123' } }, + }) + + // Must print a warning about the anonymous GraphQL operation. + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Failed to intercept a GraphQL request at "POST ${endpointUrl}": 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`) +}) + +test('does not print a warning when using anonymous operation with "graphql.operation()"', async () => { + server.use( + graphql.operation(async ({ query, variables }) => { + return HttpResponse.json({ + data: { + pets: [{ name: 'Tom' }, { name: 'Jerry' }], + }, + }) + }), + ) + + const endpointUrl = httpServer.http.url('/graphql') + const response = await fetch(endpointUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: ` + query { + pets { + name + } + } + `, + }), + }) + const json = await response.json() + + // Must get the mocked response. + expect(json).toEqual({ + data: { + pets: [{ name: 'Tom' }, { name: 'Jerry' }], + }, + }) + + // Must print no warnings: operation is handled and doesn't + // have to be named since we're using "graphql.operation()". + expect(console.warn).not.toHaveBeenCalled() +}) diff --git a/test/node/graphql-api/compatibility.node.test.ts b/test/node/graphql-api/compatibility.node.test.ts index 69baec2e5..e8529f49f 100644 --- a/test/node/graphql-api/compatibility.node.test.ts +++ b/test/node/graphql-api/compatibility.node.test.ts @@ -1,6 +1,6 @@ import fetch from 'cross-fetch' import { graphql as executeGraphql, buildSchema } from 'graphql' -import { graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { createGraphQLClient, gql } from '../../support/graphql' @@ -15,10 +15,10 @@ const schema = gql` ` const server = setupServer( - graphql.query('GetUser', async (req, res, ctx) => { + graphql.query('GetUser', async ({ query }) => { const executionResult = await executeGraphql({ schema: buildSchema(schema), - source: req.body.query, + source: query, rootValue: { user: { firstName: 'John', @@ -26,10 +26,10 @@ const server = setupServer( }, }) - return res( - ctx.data(executionResult.data), - ctx.errors(executionResult.errors), - ) + return HttpResponse.json({ + data: executionResult.data, + errors: executionResult.errors, + }) }), ) diff --git a/test/node/graphql-api/cookies.node.test.ts b/test/node/graphql-api/cookies.node.test.ts index b7660bf39..356872569 100644 --- a/test/node/graphql-api/cookies.node.test.ts +++ b/test/node/graphql-api/cookies.node.test.ts @@ -4,7 +4,7 @@ import * as cookieUtils from 'cookie' import fetch from 'node-fetch' import { graphql as executeGraphql, buildSchema } from 'graphql' -import { graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { gql } from '../../support/graphql' @@ -19,10 +19,10 @@ const schema = gql` ` const server = setupServer( - graphql.query('GetUser', async (req, res, ctx) => { + graphql.query('GetUser', async ({ query }) => { const { data, errors } = await executeGraphql({ schema: buildSchema(schema), - source: req.body.query, + source: query, rootValue: { user: { firstName: 'John', @@ -30,10 +30,16 @@ const server = setupServer( }, }) - return res( - ctx.cookie('test-cookie', 'value'), - ctx.data(data), - ctx.errors(errors), + return HttpResponse.json( + { + data, + errors, + }, + { + headers: { + 'Set-Cookie': 'test-cookie=value', + }, + }, ) }), ) diff --git a/test/node/graphql-api/extensions.node.test.ts b/test/node/graphql-api/extensions.node.test.ts index ce6f8ffc8..bd9884a69 100644 --- a/test/node/graphql-api/extensions.node.test.ts +++ b/test/node/graphql-api/extensions.node.test.ts @@ -1,11 +1,11 @@ /** * @jest-environment node */ +import fetch from 'node-fetch' import type { ExecutionResult } from 'graphql' import { buildSchema, graphql as executeGraphql } from 'graphql' -import { graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -import fetch from 'node-fetch' import { gql } from '../../support/graphql' const schema = gql` @@ -18,10 +18,10 @@ const schema = gql` ` const server = setupServer( - graphql.query('GetUser', async (req, res, ctx) => { + graphql.query('GetUser', async ({ query }) => { const { data, errors } = await executeGraphql({ schema: buildSchema(schema), - source: req.body.query, + source: query, rootValue: { user: { firstName: 'John', @@ -29,16 +29,16 @@ const server = setupServer( }, }) - return res( - ctx.data(data), - ctx.errors(errors), - ctx.extensions({ + return HttpResponse.json({ + data, + errors, + extensions: { tracking: { version: 1, page: '/test', }, - }), - ) + }, + }) }), ) @@ -69,7 +69,7 @@ test('adds extensions to the original response data', async () => { }) const body: ExecutionResult = await res.json() - expect(res.status).toEqual(200) + expect(res.status).toBe(200) expect(body.data).toEqual({ user: { firstName: 'John', diff --git a/test/node/graphql-api/response-patching.node.test.ts b/test/node/graphql-api/response-patching.node.test.ts index ae3585e38..abf349fa7 100644 --- a/test/node/graphql-api/response-patching.node.test.ts +++ b/test/node/graphql-api/response-patching.node.test.ts @@ -1,20 +1,19 @@ /** * @jest-environment node */ -import { graphql } from 'msw' +import { bypass, graphql, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -import fetch from 'cross-fetch' import { graphql as executeGraphql, buildSchema } from 'graphql' import { HttpServer } from '@open-draft/test-server/http' import { createGraphQLClient, gql } from '../../support/graphql' const server = setupServer( - graphql.query('GetUser', async (req, res, ctx) => { - const originalResponse = await ctx.fetch(req) + graphql.query('GetUser', async ({ request }) => { + const originalResponse = await fetch(bypass(request)) const { requestHeaders, queryResult } = await originalResponse.json() - return res( - ctx.data({ + return HttpResponse.json({ + data: { user: { firstName: 'Christian', lastName: queryResult.data?.user?.lastName, @@ -22,9 +21,9 @@ const server = setupServer( // Setting the request headers on the response data on purpose // to access them in the response of the Apollo client. requestHeaders, - }), - ctx.errors(queryResult.errors), - ) + }, + errors: queryResult.errors, + }) }), ) @@ -87,7 +86,13 @@ test('patches a GraphQL response', async () => { fetch, }) - const res = await client({ + const res = await client<{ + user: { + firstName: string + lastName: string + } + requestHeaders: Record + }>({ query: gql` query GetUser { user { @@ -107,7 +112,7 @@ test('patches a GraphQL response', async () => { firstName: 'Christian', lastName: 'Maverick', }) - expect(res.data.requestHeaders).toHaveProperty('x-msw-bypass', 'true') - expect(res.data.requestHeaders).not.toHaveProperty('_headers') - expect(res.data.requestHeaders).not.toHaveProperty('_names') + expect(res.data?.requestHeaders).toHaveProperty('x-msw-intention', 'bypass') + expect(res.data?.requestHeaders).not.toHaveProperty('_headers') + expect(res.data?.requestHeaders).not.toHaveProperty('_names') }) diff --git a/test/node/msw-api/context/delay.node.test.ts b/test/node/msw-api/context/delay.node.test.ts index adc685ae1..92c813d5c 100644 --- a/test/node/msw-api/context/delay.node.test.ts +++ b/test/node/msw-api/context/delay.node.test.ts @@ -2,7 +2,7 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { delay, HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { performance } from 'perf_hooks' @@ -31,8 +31,9 @@ async function makeRequest(url: string) { test('uses explicit server response time', async () => { server.use( - rest.get('http://localhost/user', (req, res, ctx) => { - return res(ctx.delay(500), ctx.text('john')) + http.get('http://localhost/user', async () => { + await delay(500) + return HttpResponse.text('john') }), ) @@ -44,8 +45,9 @@ test('uses explicit server response time', async () => { test('uses realistic server response time when no duration is provided', async () => { server.use( - rest.get('http://localhost/user', (req, res, ctx) => { - return res(ctx.delay(), ctx.text('john')) + http.get('http://localhost/user', async () => { + await delay() + return HttpResponse.text('john') }), ) @@ -58,8 +60,9 @@ test('uses realistic server response time when no duration is provided', async ( test('uses realistic server response time when "real" mode is provided', async () => { server.use( - rest.get('http://localhost/user', (req, res, ctx) => { - return res(ctx.delay('real'), ctx.text('john')) + http.get('http://localhost/user', async () => { + await delay('real') + return HttpResponse.text('john') }), ) diff --git a/test/node/msw-api/req/passthrough.node.test.ts b/test/node/msw-api/req/passthrough.node.test.ts index 55000e6a5..64ef800c1 100644 --- a/test/node/msw-api/req/passthrough.node.test.ts +++ b/test/node/msw-api/req/passthrough.node.test.ts @@ -2,7 +2,7 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, passthrough, http } from 'msw' import { setupServer } from 'msw/node' import { HttpServer } from '@open-draft/test-server/http' @@ -40,8 +40,8 @@ afterAll(async () => { it('performs request as-is when returning "req.passthrough" call in the resolver', async () => { const endpointUrl = httpServer.http.url('/user') server.use( - rest.post(endpointUrl, (req) => { - return req.passthrough() + http.post(endpointUrl, () => { + return passthrough() }), ) @@ -57,11 +57,11 @@ it('performs request as-is when returning "req.passthrough" call in the resolver it('does not allow fall-through when returning "req.passthrough" call in the resolver', async () => { const endpointUrl = httpServer.http.url('/user') server.use( - rest.post(endpointUrl, (req) => { - return req.passthrough() + http.post(endpointUrl, () => { + return passthrough() }), - rest.post(endpointUrl, (req, res, ctx) => { - return res(ctx.json({ name: 'Kate' })) + http.post(endpointUrl, () => { + return HttpResponse.json({ name: 'Kate' }) }), ) @@ -74,10 +74,10 @@ it('does not allow fall-through when returning "req.passthrough" call in the res expect(console.warn).not.toHaveBeenCalled() }) -it('prints a warning and performs a request as-is if nothing was returned from the resolver', async () => { +it('performs a request as-is if nothing was returned from the resolver', async () => { const endpointUrl = httpServer.http.url('/user') server.use( - rest.post(endpointUrl, () => { + http.post(endpointUrl, () => { return }), ) @@ -88,12 +88,4 @@ it('prints a warning and performs a request as-is if nothing was returned from t expect(json).toEqual({ name: 'John', }) - - const warning = (console.warn as any as jest.SpyInstance).mock.calls[0][0] - - expect(warning).toContain( - '[MSW] Expected response resolver to return a mocked response Object, but got undefined. The original response is going to be used instead.', - ) - expect(warning).toContain(`POST ${endpointUrl}`) - expect(console.warn).toHaveBeenCalledTimes(1) }) diff --git a/test/node/msw-api/res/network-error.node.test.ts b/test/node/msw-api/res/network-error.node.test.ts index 8de52578f..6caaafaa7 100644 --- a/test/node/msw-api/res/network-error.node.test.ts +++ b/test/node/msw-api/res/network-error.node.test.ts @@ -1,23 +1,20 @@ /** * @jest-environment node */ -import fetch from 'node-fetch' -import { rest } from 'msw' +import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -const server = setupServer() +const server = setupServer( + http.get('http://example.com/user', () => { + return HttpResponse.error() + }), +) beforeAll(() => server.listen()) afterAll(() => server.close()) test('throws a network error when used with fetch', async () => { - server.use( - rest.get('http://test.io/user', (_, res) => { - return res.networkError('Custom network error message') - }), - ) - - await expect(fetch('http://test.io/user')).rejects.toThrow( - 'Custom network error message', + await expect(fetch('http://example.com/user')).rejects.toThrow( + 'Failed to fetch', ) }) diff --git a/test/node/msw-api/setup-server/input-validation.node.test.ts b/test/node/msw-api/setup-server/input-validation.node.test.ts index 4c9d0cb16..38c0fb342 100644 --- a/test/node/msw-api/setup-server/input-validation.node.test.ts +++ b/test/node/msw-api/setup-server/input-validation.node.test.ts @@ -1,14 +1,14 @@ -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' -test('throws an error given an Array of request handlers to setupServer', async () => { +test('throws an error given an Array of request handlers to setupServer', () => { const createServer = () => { // The next line will be ignored because we want to test that an Error // should be thrown when `setupServer` parameters are not valid // @ts-ignore return setupServer([ - rest.get('https://test.mswjs.io/book/:bookId', (req, res, ctx) => { - return res(ctx.json({ title: 'Original title' })) + http.get('https://test.mswjs.io/book/:bookId', () => { + return HttpResponse.json({ title: 'Original title' }) }), ]) } diff --git a/test/node/msw-api/setup-server/life-cycle-events/on.node.test.ts b/test/node/msw-api/setup-server/life-cycle-events/on.node.test.ts index 6d49aad74..932fa2d81 100644 --- a/test/node/msw-api/setup-server/life-cycle-events/on.node.test.ts +++ b/test/node/msw-api/setup-server/life-cycle-events/on.node.test.ts @@ -2,7 +2,7 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { HttpServer } from '@open-draft/test-server/http' import { waitFor } from '../../../../support/waitFor' @@ -30,45 +30,47 @@ beforeAll(async () => { await httpServer.listen() server.use( - rest.get(httpServer.http.url('/user'), (req, res, ctx) => { - return res(ctx.text('response-body')) + http.get(httpServer.http.url('/user'), () => { + return HttpResponse.text('response-body') }), - rest.post(httpServer.http.url('/no-response'), () => { + http.post(httpServer.http.url('/no-response'), () => { return }), - rest.get(httpServer.http.url('/unhandled-exception'), () => { + http.get(httpServer.http.url('/unhandled-exception'), () => { throw new Error('Unhandled resolver error') }), ) server.listen() - server.events.on('request:start', (req) => { - listener(`[request:start] ${req.method} ${req.url.href} ${req.id}`) + server.events.on('request:start', ({ request, requestId }) => { + listener(`[request:start] ${request.method} ${request.url} ${requestId}`) }) - server.events.on('request:match', (req) => { - listener(`[request:match] ${req.method} ${req.url.href} ${req.id}`) + server.events.on('request:match', ({ request, requestId }) => { + listener(`[request:match] ${request.method} ${request.url} ${requestId}`) }) - server.events.on('request:unhandled', (req) => { - listener(`[request:unhandled] ${req.method} ${req.url.href} ${req.id}`) + server.events.on('request:unhandled', ({ request, requestId }) => { + listener( + `[request:unhandled] ${request.method} ${request.url} ${requestId}`, + ) }) - server.events.on('request:end', (req) => { - listener(`[request:end] ${req.method} ${req.url.href} ${req.id}`) + server.events.on('request:end', ({ request, requestId }) => { + listener(`[request:end] ${request.method} ${request.url} ${requestId}`) }) - server.events.on('response:mocked', (res, requestId) => { - listener(`[response:mocked] ${res.body} ${requestId}`) + server.events.on('response:mocked', async ({ response, requestId }) => { + listener(`[response:mocked] ${await response.text()} ${requestId}`) }) - server.events.on('response:bypass', (res, requestId) => { - listener(`[response:bypass] ${res.body} ${requestId}`) + server.events.on('response:bypass', async ({ response, requestId }) => { + listener(`[response:bypass] ${await response.text()} ${requestId}`) }) - server.events.on('unhandledException', (error, req) => { + server.events.on('unhandledException', ({ error, request, requestId }) => { listener( - `[unhandledException] ${req.method} ${req.url.href} ${req.id} ${error.message}`, + `[unhandledException] ${request.method} ${request.url} ${requestId} ${error.message}`, ) }) }) @@ -93,6 +95,12 @@ test('emits events for a handler request and mocked response', async () => { await fetch(url) const requestId = getRequestId(listener) + await waitFor(() => { + expect(listener).toHaveBeenCalledWith( + expect.stringContaining('[response:mocked]'), + ) + }) + expect(listener).toHaveBeenNthCalledWith( 1, `[request:start] GET ${url} ${requestId}`, diff --git a/test/node/msw-api/setup-server/life-cycle-events/removeAllListeners.node.test.ts b/test/node/msw-api/setup-server/life-cycle-events/removeAllListeners.node.test.ts index e36722aa1..bc96068ef 100644 --- a/test/node/msw-api/setup-server/life-cycle-events/removeAllListeners.node.test.ts +++ b/test/node/msw-api/setup-server/life-cycle-events/removeAllListeners.node.test.ts @@ -2,7 +2,7 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { HttpServer } from '@open-draft/test-server/http' @@ -18,8 +18,8 @@ beforeAll(async () => { await httpServer.listen() server.use( - rest.get(httpServer.http.url('/user'), (req, res, ctx) => { - return res(ctx.json({ firstName: 'John' })) + http.get(httpServer.http.url('/user'), () => { + return HttpResponse.json({ firstName: 'John' }) }), ) server.listen() diff --git a/test/node/msw-api/setup-server/life-cycle-events/removeListener.node.test.ts b/test/node/msw-api/setup-server/life-cycle-events/removeListener.node.test.ts index a50299be6..bf40845b9 100644 --- a/test/node/msw-api/setup-server/life-cycle-events/removeListener.node.test.ts +++ b/test/node/msw-api/setup-server/life-cycle-events/removeListener.node.test.ts @@ -2,7 +2,7 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { HttpServer } from '@open-draft/test-server/http' @@ -18,8 +18,8 @@ beforeAll(async () => { await httpServer.listen() server.use( - rest.get(httpServer.http.url('/user'), (req, res, ctx) => { - return res(ctx.json({ firstName: 'John' })) + http.get(httpServer.http.url('/user'), () => { + return HttpResponse.json({ firstName: 'John' }) }), ) server.listen() diff --git a/test/node/msw-api/setup-server/listHandlers.node.test.ts b/test/node/msw-api/setup-server/listHandlers.node.test.ts index 2abe6f41f..c542579ac 100644 --- a/test/node/msw-api/setup-server/listHandlers.node.test.ts +++ b/test/node/msw-api/setup-server/listHandlers.node.test.ts @@ -1,14 +1,14 @@ /** * @jest-environment node */ -import { rest, graphql } from 'msw' +import { http, graphql } from 'msw' import { setupServer } from 'msw/node' const resolver = () => null const github = graphql.link('https://api.github.com') const server = setupServer( - rest.get('https://test.mswjs.io/book/:bookId', resolver), + http.get('https://test.mswjs.io/book/:bookId', resolver), graphql.query('GetUser', resolver), graphql.mutation('UpdatePost', resolver), graphql.operation(resolver), @@ -58,7 +58,7 @@ test('forbids from modifying the list of handlers', () => { test('includes runtime request handlers when listing handlers', () => { server.use( - rest.get('https://test.mswjs.io/book/:bookId', resolver), + http.get('https://test.mswjs.io/book/:bookId', resolver), graphql.query('GetRandomNumber', resolver), ) diff --git a/test/node/msw-api/setup-server/printHandlers.node.test.ts b/test/node/msw-api/setup-server/printHandlers.node.test.ts deleted file mode 100644 index bb812cd70..000000000 --- a/test/node/msw-api/setup-server/printHandlers.node.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @jest-environment node - */ -import { bold } from 'chalk' -import { rest, graphql } from 'msw' -import { setupServer } from 'msw/node' - -const resolver = () => void 0 - -const github = graphql.link('https://api.github.com') - -const server = setupServer( - rest.get('https://test.mswjs.io/book/:bookId', resolver), - graphql.query('GetUser', resolver), - graphql.mutation('UpdatePost', resolver), - graphql.operation(resolver), - github.query('GetRepo', resolver), - github.operation(resolver), -) - -beforeAll(() => { - server.listen() -}) - -beforeEach(() => { - jest.spyOn(global.console, 'log').mockImplementation() -}) - -afterEach(() => { - jest.restoreAllMocks() - server.resetHandlers() -}) - -afterAll(() => { - server.close() -}) - -test('lists all current request handlers', () => { - server.printHandlers() - - // Test failed here, commenting so it shows up in the PR - expect(console.log).toBeCalledTimes(6) - - expect(console.log).toBeCalledWith(`\ -${bold('[rest] GET https://test.mswjs.io/book/:bookId')} - Declaration: ${__filename}:13:8 -`) - - expect(console.log).toBeCalledWith(`\ -${bold('[graphql] query GetUser (origin: *)')} - Declaration: ${__filename}:14:11 -`) - - expect(console.log).toBeCalledWith(`\ -${bold('[graphql] mutation UpdatePost (origin: *)')} - Declaration: ${__filename}:15:11 -`) - - expect(console.log).toBeCalledWith(`\ -${bold('[graphql] all (origin: *)')} - Declaration: ${__filename}:16:11 -`) - - expect(console.log).toBeCalledWith(`\ -${bold('[graphql] query GetRepo (origin: https://api.github.com)')} - Declaration: ${__filename}:17:10 -`) - - expect(console.log).toBeCalledWith(`\ -${bold('[graphql] all (origin: https://api.github.com)')} - Declaration: ${__filename}:18:10 -`) -}) - -test('respects runtime request handlers when listing handlers', () => { - server.use( - rest.get('https://test.mswjs.io/book/:bookId', resolver), - graphql.query('GetRandomNumber', resolver), - ) - - server.printHandlers() - - // Runtime handlers are prepended to the list of handlers - // and they DON'T remove the handlers they may override. - expect(console.log).toBeCalledTimes(8) - - expect(console.log).toBeCalledWith(`\ -${bold('[rest] GET https://test.mswjs.io/book/:bookId')} - Declaration: ${__filename}:77:10 -`) - - expect(console.log).toBeCalledWith(`\ -${bold('[graphql] query GetRandomNumber (origin: *)')} - Declaration: ${__filename}:78:13 -`) -}) diff --git a/test/node/msw-api/setup-server/resetHandlers.node.test.ts b/test/node/msw-api/setup-server/resetHandlers.node.test.ts index 4173eb4fb..822da11af 100644 --- a/test/node/msw-api/setup-server/resetHandlers.node.test.ts +++ b/test/node/msw-api/setup-server/resetHandlers.node.test.ts @@ -2,12 +2,12 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' const server = setupServer( - rest.get('https://test.mswjs.io/books', (req, res, ctx) => { - return res(ctx.json({ title: 'Original title' })) + http.get('https://test.mswjs.io/books', () => { + return HttpResponse.json({ title: 'Original title' }) }), ) @@ -23,8 +23,8 @@ afterAll(() => { test('removes all runtime request handlers when resetting without explicit next handlers', async () => { server.use( - rest.post('https://test.mswjs.io/login', (req, res, ctx) => { - return res(ctx.json({ accepted: true })) + http.post('https://test.mswjs.io/login', () => { + return HttpResponse.json({ accepted: true }) }), ) @@ -53,16 +53,16 @@ test('removes all runtime request handlers when resetting without explicit next test('replaces all handlers with the explicit next runtime handlers upon reset', async () => { server.use( - rest.post('https://test.mswjs.io/login', (req, res, ctx) => { - return res(ctx.json({ accepted: true })) + http.post('https://test.mswjs.io/login', () => { + return HttpResponse.json({ accepted: true }) }), ) // Once reset with explicit next requets handlers, // replaces all present requets handlers with those. server.resetHandlers( - rest.get('https://test.mswjs.io/products', (req, res, ctx) => { - return res(ctx.json([1, 2, 3])) + http.get('https://test.mswjs.io/products', () => { + return HttpResponse.json([1, 2, 3]) }), ) diff --git a/test/node/msw-api/setup-server/restoreHandlers.node.test.ts b/test/node/msw-api/setup-server/restoreHandlers.node.test.ts index b8862c925..bd2c6a2b8 100644 --- a/test/node/msw-api/setup-server/restoreHandlers.node.test.ts +++ b/test/node/msw-api/setup-server/restoreHandlers.node.test.ts @@ -2,12 +2,12 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' const server = setupServer( - rest.get('https://test.mswjs.io/book/:bookId', (req, res, ctx) => { - return res(ctx.json({ title: 'Original title' })) + http.get('https://test.mswjs.io/book/:bookId', () => { + return HttpResponse.json({ title: 'Original title' }) }), ) @@ -16,9 +16,13 @@ afterAll(() => server.close()) test('returns a mocked response from the used one-time request handler when restored', async () => { server.use( - rest.get('https://test.mswjs.io/book/:bookId', (req, res, ctx) => { - return res.once(ctx.json({ title: 'Overridden title' })) - }), + http.get( + 'https://test.mswjs.io/book/:bookId', + () => { + return HttpResponse.json({ title: 'Overridden title' }) + }, + { once: true }, + ), ) const firstResponse = await fetch('https://test.mswjs.io/book/abc-123') diff --git a/test/node/msw-api/setup-server/scenarios/cookies-request.node.test.ts b/test/node/msw-api/setup-server/scenarios/cookies-request.node.test.ts index 66e573962..ea39cb68e 100644 --- a/test/node/msw-api/setup-server/scenarios/cookies-request.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/cookies-request.node.test.ts @@ -2,28 +2,21 @@ * @jest-environment node */ import https from 'https' -import { rest } from 'msw' +import { http, HttpResponse } from 'msw' import { setupServer, SetupServerApi } from 'msw/node' -import { HttpServer } from '@open-draft/test-server/http' +import { httpsAgent, HttpServer } from '@open-draft/test-server/http' import { waitForClientRequest } from '../../../../support/utils' -let server: SetupServerApi - const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { res.json({ works: false }) }) }) +const server = setupServer() + beforeAll(async () => { await httpServer.listen() - - server = setupServer( - rest.get(httpServer.https.url('/user'), (req, res, ctx) => { - return res(ctx.json({ cookies: req.cookies })) - }), - ) - server.listen() }) @@ -33,15 +26,25 @@ afterAll(async () => { }) test('has access to request cookies', async () => { - const url = new URL(httpServer.https.url('/user')) + const endpointUrl = httpServer.https.url('/user') + + server.use( + http.get(endpointUrl, ({ cookies }) => { + return HttpResponse.json({ cookies }) + }), + ) + + const url = new URL(endpointUrl) const request = https.get({ protocol: url.protocol, - host: url.host, + hostname: url.hostname, path: url.pathname, + port: url.port, headers: { Cookie: 'auth-token=abc-123', }, + agent: httpsAgent, }) const { responseText } = await waitForClientRequest(request) diff --git a/test/node/msw-api/setup-server/scenarios/custom-transformers.node.test.ts b/test/node/msw-api/setup-server/scenarios/custom-transformers.node.test.ts index 39bc1099e..f0aec6dae 100644 --- a/test/node/msw-api/setup-server/scenarios/custom-transformers.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/custom-transformers.node.test.ts @@ -1,22 +1,20 @@ import fetch from 'node-fetch' import * as JSONbig from 'json-bigint' -import { ResponseTransformer, compose, context, rest } from 'msw' +import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -const jsonBig = (body: Record): ResponseTransformer => { - return compose( - context.set('Content-Type', 'application/json'), - context.body(JSONbig.stringify(body)), - ) -} - const server = setupServer( - rest.get('http://test.mswjs.io/me', (req, res) => { - return res( - jsonBig({ + http.get('http://test.mswjs.io/me', () => { + return new HttpResponse( + JSONbig.stringify({ username: 'john.maverick', balance: BigInt(1597928668063727616), }), + { + headers: { + 'Content-Tpye': 'application/json', + }, + }, ) }), ) diff --git a/test/node/msw-api/setup-server/scenarios/fake-timers.node.test.ts b/test/node/msw-api/setup-server/scenarios/fake-timers.node.test.ts index a6835f560..15caee6e2 100644 --- a/test/node/msw-api/setup-server/scenarios/fake-timers.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/fake-timers.node.test.ts @@ -1,10 +1,10 @@ import fetch from 'node-fetch' import { setupServer } from 'msw/node' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' const server = setupServer( - rest.get('https://test.mswjs.io/pull', (req, res, ctx) => { - return res(ctx.json({ status: 'pulled' })) + http.get('https://test.mswjs.io/pull', () => { + return HttpResponse.json({ status: 'pulled' }) }), ) diff --git a/test/node/msw-api/setup-server/scenarios/fall-through.node.test.ts b/test/node/msw-api/setup-server/scenarios/fall-through.node.test.ts index e1f128141..26f84b4f7 100644 --- a/test/node/msw-api/setup-server/scenarios/fall-through.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/fall-through.node.test.ts @@ -2,31 +2,32 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' const log = jest.fn() const server = setupServer( - rest.get('https://test.mswjs.io/*', () => log('[get] first')), - rest.get('https://test.mswjs.io/us*', () => log('[get] second')), - rest.get('https://test.mswjs.io/user', (req, res, ctx) => - res(ctx.json({ firstName: 'John' })), - ), - rest.get('https://test.mswjs.io/user', () => log('[get] third')), - - rest.post('https://test.mswjs.io/blog/*', () => log('[post] first')), - rest.post('https://test.mswjs.io/blog/article', () => log('[post] second')), + http.get('https://test.mswjs.io/*', () => log('[get] first')), + http.get('https://test.mswjs.io/us*', () => log('[get] second')), + http.get('https://test.mswjs.io/user', () => { + return HttpResponse.json({ firstName: 'John' }) + }), + http.get('https://test.mswjs.io/user', () => log('[get] third')), + + http.post('https://test.mswjs.io/blog/*', () => log('[post] first')), + http.post('https://test.mswjs.io/blog/article', () => log('[post] second')), ) beforeAll(() => { - // Supress the "Expeted mocking resolver function to return a mocked response" warnings. - jest.spyOn(global.console, 'warn').mockImplementation() server.listen() }) +afterEach(() => { + jest.resetAllMocks() +}) + afterAll(() => { - jest.restoreAllMocks() server.close() }) @@ -37,8 +38,8 @@ test('falls through all relevant request handlers until response is returned', a expect(body).toEqual({ firstName: 'John', }) - expect(log).toBeCalledWith('[get] first') - expect(log).toBeCalledWith('[get] second') + expect(log).toHaveBeenNthCalledWith(1, '[get] first') + expect(log).toHaveBeenNthCalledWith(2, '[get] second') expect(log).not.toBeCalledWith('[get] third') }) @@ -49,6 +50,6 @@ test('falls through all relevant handlers even if none return response', async ( const { status } = res expect(status).toBe(404) - expect(log).toBeCalledWith('[post] first') - expect(log).toBeCalledWith('[post] second') + expect(log).toHaveBeenNthCalledWith(1, '[post] first') + expect(log).toHaveBeenNthCalledWith(2, '[post] second') }) diff --git a/test/node/msw-api/setup-server/scenarios/fetch.node.test.ts b/test/node/msw-api/setup-server/scenarios/fetch.node.test.ts index 4abc9b184..64af804a1 100644 --- a/test/node/msw-api/setup-server/scenarios/fetch.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/fetch.node.test.ts @@ -1,94 +1,68 @@ -/** - * @jest-environment node - */ -import fetch, { Response } from 'node-fetch' -import { rest } from 'msw' +import fetch from 'node-fetch' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' -describe('setupServer / fetch', () => { - const server = setupServer( - rest.get('http://test.mswjs.io', (req, res, ctx) => { - return res( - ctx.status(401), - ctx.set('x-header', 'yes'), - ctx.json({ - firstName: 'John', - age: 32, - }), - ) - }), - rest.post('https://test.mswjs.io', (req, res, ctx) => { - return res( - ctx.status(403), - ctx.set('x-header', 'yes'), - ctx.json(req.body as Record), - ) - }), - ) - - beforeAll(() => { - server.listen() - }) - - afterAll(() => { - server.close() - }) - - describe('given I perform a GET request using fetch', () => { - let res: Response - - beforeAll(async () => { - res = await fetch('http://test.mswjs.io') - }) - - test('should return mocked status code', async () => { - expect(res.status).toEqual(401) - }) - - test('should return mocked headers', () => { - expect(res.headers.get('content-type')).toEqual('application/json') - expect(res.headers.get('x-header')).toEqual('yes') - }) - - test('should return mocked body', async () => { - const body = await res.json() - - expect(body).toEqual({ +const server = setupServer( + http.get('http://test.mswjs.io', () => { + return HttpResponse.json( + { firstName: 'John', age: 32, - }) - }) - }) - - describe('given I perform a POST request using fetch', () => { - let res: Response - - beforeAll(async () => { - res = await fetch('https://test.mswjs.io', { - method: 'POST', + }, + { + status: 401, headers: { - 'Content-Type': 'application/json', + 'X-Header': 'yes', }, - body: JSON.stringify({ - payload: 'info', - }), - }) + }, + ) + }), + http.post('https://test.mswjs.io', async ({ request }) => { + return HttpResponse.json(await request.json(), { + status: 403, + headers: { + 'X-Header': 'yes', + }, }) + }), +) - test('should return mocked status code', () => { - expect(res.status).toEqual(403) - }) +beforeAll(() => { + server.listen() +}) - test('should return mocked headers', () => { - expect(res.headers.get('content-type')).toEqual('application/json') - expect(res.headers.get('x-header')).toEqual('yes') - }) +afterAll(() => { + server.close() +}) - test('should return mocked and parsed JSON body', async () => { - const body = await res.json() - expect(body).toEqual({ - payload: 'info', - }) - }) +it('returns a mocked response to a GET request using fetch', async () => { + const res = await fetch('http://test.mswjs.io') + + expect(res.status).toEqual(401) + expect(res.headers.get('content-type')).toEqual('application/json') + expect(res.headers.get('x-header')).toEqual('yes') + + expect(await res.json()).toEqual({ + firstName: 'John', + age: 32, + }) +}) + +it('returns a mocked response to a POST request using fetch', async () => { + const res = await fetch('https://test.mswjs.io', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + payload: 'info', + }), + }) + + expect(res.status).toEqual(403) + expect(res.headers.get('content-type')).toEqual('application/json') + expect(res.headers.get('x-header')).toEqual('yes') + expect(await res.json()).toEqual({ + payload: 'info', }) }) diff --git a/test/node/msw-api/setup-server/scenarios/generator.node.test.ts b/test/node/msw-api/setup-server/scenarios/generator.node.test.ts index 974cc62c7..bc4dd8a81 100644 --- a/test/node/msw-api/setup-server/scenarios/generator.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/generator.node.test.ts @@ -2,63 +2,56 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' const server = setupServer( - rest.get( + http.get<{ maxCount: string }>( 'https://example.com/polling/:maxCount', - function* (req, res, ctx) { - const maxCount = parseInt(req.params.maxCount) + function* ({ params }) { + const maxCount = parseInt(params.maxCount) let count = 0 while (count < maxCount) { count += 1 - yield res( - ctx.json({ - status: 'pending', - count, - }), - ) + yield HttpResponse.json({ + status: 'pending', + count, + }) } - return res( - ctx.json({ - status: 'complete', - count, - }), - ) + return HttpResponse.json({ + status: 'complete', + count, + }) }, ), - rest.get( + http.get<{ maxCount: string }>( 'https://example.com/polling/once/:maxCount', - function* (req, res, ctx) { - const maxCount = parseInt(req.params.maxCount) + function* ({ params }) { + const maxCount = parseInt(params.maxCount) let count = 0 while (count < maxCount) { count += 1 - yield res( - ctx.json({ - status: 'pending', - count, - }), - ) + yield HttpResponse.json({ + status: 'pending', + count, + }) } - return res.once( - ctx.json({ - status: 'complete', - count, - }), - ) + return HttpResponse.json({ + status: 'complete', + count, + }) }, + { once: true }, ), - rest.get( + http.get<{ maxCount: string }>( 'https://example.com/polling/once/:maxCount', - (req, res, ctx) => { - return res(ctx.json({ status: 'done' })) + () => { + return HttpResponse.json({ status: 'done' }) }, ), ) @@ -81,7 +74,6 @@ test('supports generator as the response resolver', async () => { const res = await fetch('https://example.com/polling/3') const body = await res.json() expect(res.status).toBe(200) - expect(res.headers.get('x-powered-by')).toEqual('msw') expect(body).toEqual(expectedBody) } @@ -108,7 +100,6 @@ test('supports one-time handlers with the generator as the response resolver', a const res = await fetch('https://example.com/polling/once/3') const body = await res.json() expect(res.status).toBe(200) - expect(res.headers.get('x-powered-by')).toEqual('msw') expect(body).toEqual(expectedBody) } diff --git a/test/node/msw-api/setup-server/scenarios/graphql.node.test.ts b/test/node/msw-api/setup-server/scenarios/graphql.node.test.ts index 5a88dc6a0..4d9d99e55 100644 --- a/test/node/msw-api/setup-server/scenarios/graphql.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/graphql.node.test.ts @@ -2,7 +2,7 @@ * @jest-environment node */ import fetch from 'cross-fetch' -import { graphql } from 'msw' +import { graphql, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { createGraphQLClient, gql } from '../../../../support/graphql' @@ -30,24 +30,24 @@ const LOGIN = gql` ` const server = setupServer( - graphql.query('GetUserDetail', (req, res, ctx) => { - const { userId } = req.variables + graphql.query('GetUserDetail', ({ variables }) => { + const { userId } = variables - return res( - ctx.data({ + return HttpResponse.json({ + data: { user: { id: userId, firstName: 'John', age: 32, }, - }), - ) + }, + }) }), - graphql.mutation('Login', (req, res, ctx) => { - const { username } = req.variables + graphql.mutation('Login', ({ variables }) => { + const { username } = variables - return res( - ctx.errors([ + return HttpResponse.json({ + errors: [ { message: `User "${username}" is not found`, locations: [ @@ -57,8 +57,8 @@ const server = setupServer( }, ], }, - ]), - ) + ], + }) }), ) diff --git a/test/node/msw-api/setup-server/scenarios/http.node.test.ts b/test/node/msw-api/setup-server/scenarios/http.node.test.ts index b0359f2df..aeca32c49 100644 --- a/test/node/msw-api/setup-server/scenarios/http.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/http.node.test.ts @@ -1,13 +1,9 @@ /** * @jest-environment node */ -/** - * @note Do not import as wildcard lest the ESM gods be displeased. - * Make sure "allowSyntheticDefaultImports" is true in tsconfig.json. - */ -import http from 'http' +import nodeHttp from 'http' import { HttpServer } from '@open-draft/test-server/http' -import { rest } from 'msw' +import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { waitForClientRequest } from '../../../../support/utils' @@ -26,11 +22,15 @@ beforeAll(async () => { beforeEach(() => { server.use( - rest.get(httpServer.http.url('/resource'), (req, res, ctx) => { - return res( - ctx.status(401), - ctx.set('x-header', 'yes'), - ctx.json({ firstName: 'John' }), + http.get(httpServer.http.url('/resource'), () => { + return HttpResponse.json( + { firstName: 'John' }, + { + status: 401, + headers: { + 'x-header': 'yes', + }, + }, ) }), ) @@ -46,7 +46,7 @@ afterAll(async () => { }) it('returns a mocked response to an "http.get" request', async () => { - const request = http.get(httpServer.http.url('/resource')) + const request = nodeHttp.get(httpServer.http.url('/resource')) const { response, responseText } = await waitForClientRequest(request) expect(response.statusCode).toBe(401) @@ -60,7 +60,7 @@ it('returns a mocked response to an "http.get" request', async () => { }) it('returns a mocked response to an "http.request" request', async () => { - const request = http.request(httpServer.http.url('/resource')) + const request = nodeHttp.request(httpServer.http.url('/resource')) request.end() const { response, responseText } = await waitForClientRequest(request) diff --git a/test/node/msw-api/setup-server/scenarios/https.node.test.ts b/test/node/msw-api/setup-server/scenarios/https.node.test.ts index c7ee22d52..32eee39ad 100644 --- a/test/node/msw-api/setup-server/scenarios/https.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/https.node.test.ts @@ -2,8 +2,8 @@ * @jest-environment node */ import https from 'https' -import { HttpServer } from '@open-draft/test-server/http' -import { rest } from 'msw' +import { HttpServer, httpsAgent } from '@open-draft/test-server/http' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { waitForClientRequest } from '../../../../support/utils' @@ -22,11 +22,17 @@ beforeAll(async () => { beforeEach(() => { server.use( - rest.get(httpServer.https.url('/resource'), (req, res, ctx) => { - return res( - ctx.status(401), - ctx.set('x-header', 'yes'), - ctx.json({ firstName: 'John' }), + http.get(httpServer.https.url('/resource'), () => { + return HttpResponse.json( + { + firstName: 'John', + }, + { + status: 401, + headers: { + 'X-Header': 'yes', + }, + }, ) }), ) @@ -42,7 +48,9 @@ afterAll(async () => { }) it('returns a mocked response to an "https.get" request', async () => { - const request = https.get(httpServer.https.url('/resource')) + const request = https.get(httpServer.https.url('/resource'), { + agent: httpsAgent, + }) const { response, responseText } = await waitForClientRequest(request) expect(response.statusCode).toBe(401) @@ -56,7 +64,9 @@ it('returns a mocked response to an "https.get" request', async () => { }) it('returns a mocked response to an "https.request" request', async () => { - const request = https.request(httpServer.https.url('/resource')) + const request = https.request(httpServer.https.url('/resource'), { + agent: httpsAgent, + }) request.end() const { response, responseText } = await waitForClientRequest(request) diff --git a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/bypass.node.test.ts b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/bypass.node.test.ts index a989d8b37..d35478e09 100644 --- a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/bypass.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/bypass.node.test.ts @@ -4,7 +4,7 @@ import fetch from 'node-fetch' import { HttpServer } from '@open-draft/test-server/http' import { setupServer } from 'msw/node' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { @@ -21,8 +21,8 @@ beforeAll(async () => { await httpServer.listen() server.use( - rest.get(httpServer.http.url('/user'), (req, res, ctx) => { - return res(ctx.json({ firstName: 'John' })) + http.get(httpServer.http.url('/user'), () => { + return HttpResponse.json({ firstName: 'John' }) }), ) server.listen({ onUnhandledRequest: 'bypass' }) @@ -41,7 +41,7 @@ test('bypasses unhandled requests', async () => { const res = await fetch(httpServer.http.url('/')) // Request should be performed as-is - expect(res.status).toEqual(200) + expect(res.status).toBe(200) expect(await res.text()).toEqual('root') // No warnings/errors should be printed 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 36507e7a1..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 @@ -3,28 +3,36 @@ */ import fetch from 'node-fetch' import { setupServer } from 'msw/node' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' const server = setupServer( - rest.get('https://test.mswjs.io/user', (req, res, ctx) => { - return res(ctx.json({ firstName: 'John' })) + http.get('https://test.mswjs.io/user', () => { + return HttpResponse.json({ firstName: 'John' }) }), ) beforeAll(() => server.listen({ - onUnhandledRequest(req) { - throw new Error(`Custom error for ${req.method} ${req.url}`) + onUnhandledRequest(request) { + /** + * @fixme @todo For some reason, the exception from the "onUnhandledRequest" + * 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}`) }, }), ) -afterAll(() => server.close()) + +afterAll(() => { + server.close() +}) test('prevents a request when a custom callback throws an exception', async () => { - const getResponse = () => fetch('https://test.mswjs.io') + const getResponse = () => fetch('https://example.com') // Request should be cancelled with a fetch error, since the callback threw. await expect(getResponse()).rejects.toThrow( - 'request to https://test.mswjs.io/ failed, reason: Custom error for GET https://test.mswjs.io/', + 'request to https://example.com/ failed, reason: Custom error for GET https://example.com/', ) }) diff --git a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback.node.test.ts b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback.node.test.ts index 251c652a9..13799b6fe 100644 --- a/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/on-unhandled-request/callback.node.test.ts @@ -3,15 +3,15 @@ */ import fetch from 'node-fetch' import { setupServer } from 'msw/node' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' const server = setupServer( - rest.get('https://test.mswjs.io/user', (req, res, ctx) => { - return res(ctx.json({ firstName: 'John' })) + http.get('https://test.mswjs.io/user', () => { + return HttpResponse.json({ firstName: 'John' }) }), ) -const logs = [] +const logs: Array = [] beforeAll(() => server.listen({ 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 1e7103e74..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 @@ -3,11 +3,11 @@ */ import fetch from 'node-fetch' import { setupServer } from 'msw/node' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' const server = setupServer( - rest.get('https://test.mswjs.io/user', (req, res, ctx) => { - return res(ctx.json({ firstName: 'John' })) + http.get('https://test.mswjs.io/user', () => { + return HttpResponse.json({ firstName: 'John' }) }), ) @@ -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 7d87e0cdb..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 @@ -3,7 +3,7 @@ */ import fetch from 'node-fetch' import { HttpServer } from '@open-draft/test-server/http' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' const httpServer = new HttpServer((app) => { @@ -23,15 +23,15 @@ beforeAll(async () => { await httpServer.listen() server.use( - rest.get(httpServer.http.url('/user'), (req, res, ctx) => { - return res(ctx.json({ mocked: true })) + http.get(httpServer.http.url('/user'), () => { + return HttpResponse.json({ mocked: true }) }), - rest.post(httpServer.http.url('/explicit-return'), () => { + http.post(httpServer.http.url('/explicit-return'), () => { // Short-circuiting in a handler makes it perform the request as-is, // but still treats this request as handled. return }), - rest.post(httpServer.http.url('/implicit-return'), () => { + http.post(httpServer.http.url('/implicit-return'), () => { // The handler that has no return also performs the request as-is, // still treating this request as handled. }), @@ -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 f88f1472a..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 @@ -3,11 +3,11 @@ */ import fetch from 'node-fetch' import { setupServer } from 'msw/node' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' const server = setupServer( - rest.get('https://test.mswjs.io/user', (req, res, ctx) => { - return res(ctx.json({ firstName: 'John' })) + http.get('https://test.mswjs.io/user', () => { + return HttpResponse.json({ firstName: 'John' }) }), ) @@ -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/relative-url.node.test.ts b/test/node/msw-api/setup-server/scenarios/relative-url.node.test.ts index 042232a53..85b1f37fe 100644 --- a/test/node/msw-api/setup-server/scenarios/relative-url.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/relative-url.node.test.ts @@ -2,19 +2,20 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' const server = setupServer( - rest.get('/books', (req, res, ctx) => { - return res(ctx.json([1, 2, 3])) + http.get('/books', () => { + return HttpResponse.json([1, 2, 3]) }), - rest.get('https://api.backend.com/path', (req, res, ctx) => { - return res(ctx.json({ success: true })) + http.get('https://api.backend.com/path', () => { + return HttpResponse.json({ success: true }) }), ) beforeAll(() => server.listen()) + afterAll(() => server.close()) test('tolerates relative request handlers on the server', async () => { 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 7931aed34..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 @@ -1,9 +1,8 @@ /** * @jest-environment node */ -import fetch from 'node-fetch' import { HttpServer } from '@open-draft/test-server/http' -import { rest } from 'msw' +import { HttpResponse, http, bypass } from 'msw' import { setupServer } from 'msw/node' const httpServer = new HttpServer((app) => { @@ -21,45 +20,42 @@ interface ResponseBody { } const server = setupServer( - rest.get( - 'https://test.mswjs.io/user', - async (req, res, ctx) => { - const originalResponse = await ctx.fetch(httpServer.http.url('/user')) - const body = await originalResponse.json() - - return res( - ctx.json({ - id: body.id, - mocked: true, - }), - ) - }, - ), - rest.get( - 'https://test.mswjs.io/complex-request', - async (req, res, ctx) => { - const shouldBypass = req.url.searchParams.get('bypass') === 'true' - const performRequest = shouldBypass - ? () => - ctx - .fetch(httpServer.http.url('/user'), { method: 'POST' }) - .then((res) => res.json()) - : () => - fetch('https://httpbin.org/post', { method: 'POST' }).then((res) => - res.json(), - ) - const originalResponse = await performRequest() - - return res( - ctx.json({ - id: originalResponse.id, - mocked: true, - }), - ) - }, - ), - rest.post('https://httpbin.org/post', (req, res, ctx) => { - return res(ctx.json({ id: 303 })) + http.get('https://test.mswjs.io/user', async () => { + const originalResponse = await fetch(bypass(httpServer.http.url('/user'))) + const body = await originalResponse.json() + + return HttpResponse.json({ + id: body.id, + mocked: true, + }) + }), + http.get('https://test.mswjs.io/complex-request', async ({ request }) => { + const url = new URL(request.url) + + const shouldBypass = url.searchParams.get('bypass') === 'true' + const performRequest = shouldBypass + ? () => + 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(), + ) + + const originalResponse = await performRequest() + + return HttpResponse.json({ + id: originalResponse.id, + mocked: true, + }) + }), + http.post('https://httpbin.org/post', () => { + return HttpResponse.json({ id: 303 }) }), ) @@ -79,22 +75,20 @@ afterAll(async () => { test('returns a combination of mocked and original responses', async () => { const res = await fetch('https://test.mswjs.io/user') - const { status, headers } = res + const { status } = res const body = await res.json() expect(status).toBe(200) - expect(headers.get('x-powered-by')).toBe('msw') expect(body).toEqual({ id: 101, mocked: true, }) }) -test('bypasses a mocked request when using "ctx.fetch"', async () => { +test('bypasses a mocked request when using "bypass()"', async () => { const res = await fetch('https://test.mswjs.io/complex-request?bypass=true') expect(res.status).toBe(200) - expect(res.headers.get('x-powered-by')).toBe('msw') expect(await res.json()).toEqual({ id: 202, mocked: true, @@ -105,7 +99,6 @@ test('falls into the mocked request when using "fetch" directly', async () => { const res = await fetch('https://test.mswjs.io/complex-request') expect(res.status).toBe(200) - expect(res.headers.get('x-powered-by')).toBe('msw') expect(await res.json()).toEqual({ id: 303, mocked: true, diff --git a/test/node/msw-api/setup-server/scenarios/xhr.node.test.ts b/test/node/msw-api/setup-server/scenarios/xhr.node.test.ts index 25042d9ed..ea98d859c 100644 --- a/test/node/msw-api/setup-server/scenarios/xhr.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/xhr.node.test.ts @@ -1,19 +1,25 @@ /** * @jest-environment jsdom */ -import { rest } from 'msw' +import { http } from 'msw' import { setupServer } from 'msw/node' import { stringToHeaders } from 'headers-polyfill' const server = setupServer( - rest.get('http://test.mswjs.io', (req, res, ctx) => { - return res( - ctx.status(401), - ctx.set('x-header', 'yes'), - ctx.json({ + http.get('http://localhost:3001/resource', ({ request }) => { + return new Response( + JSON.stringify({ firstName: 'John', age: 32, }), + { + status: 401, + statusText: 'Unauthorized', + headers: { + 'Content-Type': 'application/json', + 'X-Header': 'yes', + }, + }, ) }), ) @@ -33,7 +39,7 @@ describe('given I perform an XMLHttpRequest', () => { beforeAll((done) => { const req = new XMLHttpRequest() - req.open('GET', 'http://test.mswjs.io') + req.open('GET', 'http://localhost:3001/resource') req.onload = function () { statusCode = this.status body = JSON.parse(this.response) diff --git a/test/node/msw-api/setup-server/use.node.test.ts b/test/node/msw-api/setup-server/use.node.test.ts index bbd22e610..8e74db56d 100644 --- a/test/node/msw-api/setup-server/use.node.test.ts +++ b/test/node/msw-api/setup-server/use.node.test.ts @@ -2,8 +2,8 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' -import { setupServer, SetupServerApi } from 'msw/node' +import { HttpResponse, http } from 'msw' +import { SetupServer, setupServer } from 'msw/node' import { RequestHandler as ExpressRequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' @@ -15,16 +15,17 @@ const httpServer = new HttpServer((app) => { app.post('/login', handler) }) -let server: SetupServerApi +let server: SetupServer beforeAll(async () => { await httpServer.listen() server = setupServer( - rest.get(httpServer.http.url('/book/:bookId'), (req, res, ctx) => { - return res(ctx.json({ title: 'Original title' })) + http.get<{ bookId: string }>(httpServer.http.url('/book/:bookId'), () => { + return HttpResponse.json({ title: 'Original title' }) }), ) + server.listen() }) @@ -39,8 +40,8 @@ afterAll(async () => { test('returns a mocked response from a runtime request handler upon match', async () => { server.use( - rest.post(httpServer.http.url('/login'), (req, res, ctx) => { - return res(ctx.json({ accepted: true })) + http.post(httpServer.http.url('/login'), () => { + return HttpResponse.json({ accepted: true }) }), ) @@ -54,15 +55,14 @@ test('returns a mocked response from a runtime request handler upon match', asyn // Other request handlers are preserved, if there are no overlaps. const bookResponse = await fetch(httpServer.http.url('/book/abc-123')) - const bookBody = await bookResponse.json() expect(bookResponse.status).toBe(200) - expect(bookBody).toEqual({ title: 'Original title' }) + expect(await bookResponse.json()).toEqual({ title: 'Original title' }) }) test('returns a mocked response from a persistent request handler override', async () => { server.use( - rest.get(httpServer.http.url('/book/:bookId'), (req, res, ctx) => { - return res(ctx.json({ title: 'Permanent override' })) + http.get<{ bookId: string }>(httpServer.http.url('/book/:bookId'), () => { + return HttpResponse.json({ title: 'Permanent override' }) }), ) @@ -72,16 +72,21 @@ test('returns a mocked response from a persistent request handler override', asy expect(bookBody).toEqual({ title: 'Permanent override' }) const anotherBookResponse = await fetch(httpServer.http.url('/book/abc-123')) - const anotherBookBody = await anotherBookResponse.json() expect(anotherBookResponse.status).toBe(200) - expect(anotherBookBody).toEqual({ title: 'Permanent override' }) + expect(await anotherBookResponse.json()).toEqual({ + title: 'Permanent override', + }) }) test('returns a mocked response from a one-time request handler override only upon first request match', async () => { server.use( - rest.get(httpServer.http.url('/book/:bookId'), (req, res, ctx) => { - return res.once(ctx.json({ title: 'One-time override' })) - }), + http.get<{ bookId: string }>( + httpServer.http.url('/book/:bookId'), + () => { + return HttpResponse.json({ title: 'One-time override' }) + }, + { once: true }, + ), ) const bookResponse = await fetch(httpServer.http.url('/book/abc-123')) @@ -90,29 +95,35 @@ test('returns a mocked response from a one-time request handler override only up expect(bookBody).toEqual({ title: 'One-time override' }) const anotherBookResponse = await fetch(httpServer.http.url('/book/abc-123')) - const anotherBookBody = await anotherBookResponse.json() expect(anotherBookResponse.status).toBe(200) - expect(anotherBookBody).toEqual({ title: 'Original title' }) + expect(await anotherBookResponse.json()).toEqual({ title: 'Original title' }) }) test('returns a mocked response from a one-time request handler override only upon first request match with parallel requests', async () => { server.use( - rest.get(httpServer.http.url('/book/:bookId'), (req, res, ctx) => { - const { bookId } = req.params - return res.once(ctx.json({ title: 'One-time override', bookId })) - }), + http.get<{ bookId: string }>( + httpServer.http.url('/book/:bookId'), + ({ params }) => { + return HttpResponse.json({ + title: 'One-time override', + bookId: params.bookId, + }) + }, + { once: true }, + ), ) const bookRequestPromise = fetch(httpServer.http.url('/book/abc-123')) const anotherBookRequestPromise = fetch(httpServer.http.url('/book/abc-123')) const bookResponse = await bookRequestPromise - const bookBody = await bookResponse.json() expect(bookResponse.status).toBe(200) - expect(bookBody).toEqual({ title: 'One-time override', bookId: 'abc-123' }) + expect(await bookResponse.json()).toEqual({ + title: 'One-time override', + bookId: 'abc-123', + }) const anotherBookResponse = await anotherBookRequestPromise - const anotherBookBody = await anotherBookResponse.json() expect(anotherBookResponse.status).toBe(200) - expect(anotherBookBody).toEqual({ title: 'Original title' }) + expect(await anotherBookResponse.json()).toEqual({ title: 'Original title' }) }) 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/test/node/rest-api/cookies-inheritance.node.test.ts b/test/node/rest-api/cookies-inheritance.node.test.ts index 30031d770..8672570f0 100644 --- a/test/node/rest-api/cookies-inheritance.node.test.ts +++ b/test/node/rest-api/cookies-inheritance.node.test.ts @@ -2,12 +2,12 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' -import { setupServer, SetupServerApi } from 'msw/node' +import { HttpResponse, http } from 'msw' +import { setupServer, SetupServer } from 'msw/node' import { HttpServer } from '@open-draft/test-server/http' import { RequestHandler as ExpressRequestHandler } from 'express' -let server: SetupServerApi +let server: SetupServer const httpServer = new HttpServer((app) => { const handler: ExpressRequestHandler = (req, res) => { @@ -21,25 +21,31 @@ beforeAll(async () => { await httpServer.listen() server = setupServer( - rest.post(httpServer.https.url('/login'), (req, res, ctx) => { - return res(ctx.cookie('authToken', 'abc-123')) + http.post(httpServer.https.url('/login'), () => { + return new HttpResponse(null, { + headers: { + 'Set-Cookie': 'authToken=abc-123', + }, + }) }), - rest.get(httpServer.https.url('/user'), (req, res, ctx) => { - if (req.cookies.authToken == null) { - return res( - ctx.status(403), - ctx.json({ + http.get< + never, + never, + { firstName: string; lastName: string } | { error: string } + >(httpServer.https.url('/user'), ({ cookies }) => { + if (cookies.authToken == null) { + return HttpResponse.json( + { error: 'Auth token not found', - }), + }, + { status: 403 }, ) } - return res( - ctx.json({ - firstName: 'John', - lastName: 'Maverick', - }), - ) + return HttpResponse.json({ + firstName: 'John', + lastName: 'Maverick', + }) }), ) diff --git a/test/node/rest-api/https.node.test.ts b/test/node/rest-api/https.node.test.ts new file mode 100644 index 000000000..72fb2a836 --- /dev/null +++ b/test/node/rest-api/https.node.test.ts @@ -0,0 +1,46 @@ +/** + * @jest-environment node + */ +import https from 'https' +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +test('intercepts and mocks a request made via "https"', (done) => { + server.use( + http.get('https://api.example.com/resource', () => { + return HttpResponse.text('Hello, world!') + }), + ) + const request = https.get('https://api.example.com/resource') + + request.on('response', (response) => { + const chunks: Array = [] + response.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + + response.on('error', done) + response.once('end', () => { + expect(chunks).toHaveLength(1) + + const responseText = Buffer.concat(chunks).toString('utf8') + expect(responseText).toBe('Hello, world!') + + done() + }) + }) + + request.on('error', done) +}) diff --git a/test/node/rest-api/request/body/body-arraybuffer.node.test.ts b/test/node/rest-api/request/body/body-arraybuffer.node.test.ts index 4fb7580cd..dd5991223 100644 --- a/test/node/rest-api/request/body/body-arraybuffer.node.test.ts +++ b/test/node/rest-api/request/body/body-arraybuffer.node.test.ts @@ -2,14 +2,17 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' -import { encodeBuffer } from '@mswjs/interceptors' + +function encodeBuffer(value: unknown) { + return Buffer.from(JSON.stringify(value)).buffer +} const server = setupServer( - rest.post('http://localhost/arrayBuffer', async (req, res, ctx) => { - const arrayBuffer = await req.arrayBuffer() - return res(ctx.body(arrayBuffer)) + http.post('http://localhost/arrayBuffer', async ({ request }) => { + const requestBodyBuffer = await request.arrayBuffer() + return HttpResponse.arrayBuffer(requestBodyBuffer) }), ) @@ -52,7 +55,7 @@ test('reads null request body as empty array buffer', async () => { headers: { 'Content-Type': 'application/json', }, - body: null, + body: undefined, }) const body = await res.arrayBuffer() diff --git a/test/node/rest-api/request/body/body-form-data.node.test.ts b/test/node/rest-api/request/body/body-form-data.node.test.ts index 4683408c1..f07d5026a 100644 --- a/test/node/rest-api/request/body/body-form-data.node.test.ts +++ b/test/node/rest-api/request/body/body-form-data.node.test.ts @@ -1,14 +1,13 @@ /** * @jest-environment node */ -import fetch from 'node-fetch' -import FormDataPolyfill from 'form-data' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' const server = setupServer( - rest.post('http://localhost/deprecated', (req, res, ctx) => { - return res(ctx.json(req.body)) + http.post('http://localhost/resource', async ({ request }) => { + const formData = await request.formData() + return HttpResponse.json(Array.from(formData.entries())) }), ) @@ -20,24 +19,23 @@ afterAll(() => { server.close() }) -test('handles "FormData" as a request body', async () => { +test('reads FormData request body', async () => { // Note that creating a `FormData` instance in Node/JSDOM differs // from the same instance in a real browser. Follow the instructions // of your `fetch` polyfill to learn more. - const formData = new FormDataPolyfill() + const formData = new FormData() formData.append('username', 'john.maverick') formData.append('password', 'secret123') - const res = await fetch('http://localhost/deprecated', { + const res = await fetch('http://localhost/resource', { method: 'POST', - headers: formData.getHeaders(), body: formData, }) const json = await res.json() expect(res.status).toBe(200) - expect(json).toEqual({ - username: 'john.maverick', - password: 'secret123', - }) + expect(json).toEqual([ + ['username', 'john.maverick'], + ['password', 'secret123'], + ]) }) diff --git a/test/node/rest-api/request/body/body-json.node.test.ts b/test/node/rest-api/request/body/body-json.node.test.ts index f72b49a4e..20f301b37 100644 --- a/test/node/rest-api/request/body/body-json.node.test.ts +++ b/test/node/rest-api/request/body/body-json.node.test.ts @@ -2,17 +2,13 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { encodeBuffer } from '@mswjs/interceptors' const server = setupServer( - rest.post('http://localhost/deprecated', (req, res, ctx) => { - return res(ctx.json(req.body)) - }), - rest.post('http://localhost/json', async (req, res, ctx) => { - const json = await req.json() - return res(ctx.json(json)) + http.post('http://localhost/json', async ({ request }) => { + return HttpResponse.json(await request.json()) }), ) @@ -24,34 +20,6 @@ afterAll(() => { server.close() }) -test('reads request body as json', async () => { - const res = await fetch('http://localhost/deprecated', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ firstName: 'John' }), - }) - const json = await res.json() - - expect(res.status).toBe(200) - expect(json).toEqual({ firstName: 'John' }) -}) - -test('reads a single number as json request body', async () => { - const res = await fetch('http://localhost/deprecated', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(123), - }) - const json = await res.json() - - expect(res.status).toBe(200) - expect(json).toEqual(123) -}) - test('reads request body using json() method', async () => { const res = await fetch('http://localhost/json', { method: 'POST', diff --git a/test/node/rest-api/request/body/body-text.node.test.ts b/test/node/rest-api/request/body/body-text.node.test.ts index 45ebe6c28..b3025686f 100644 --- a/test/node/rest-api/request/body/body-text.node.test.ts +++ b/test/node/rest-api/request/body/body-text.node.test.ts @@ -2,14 +2,13 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { encodeBuffer } from '@mswjs/interceptors' const server = setupServer( - rest.post('http://localhost/resource', async (req, res, ctx) => { - const body = await req.text() - return res(ctx.body(body)) + http.post('http://localhost/resource', async ({ request }) => { + return HttpResponse.text(await request.text()) }), ) @@ -63,7 +62,7 @@ test('reads array buffer request body as text', async () => { test('reads null request body as empty text', async () => { const res = await fetch('http://localhost/resource', { method: 'POST', - body: null, + body: null as any, }) const body = await res.text() diff --git a/test/node/rest-api/request/body/body-used.node.test.ts b/test/node/rest-api/request/body/body-used.node.test.ts new file mode 100644 index 000000000..cc6d77a5c --- /dev/null +++ b/test/node/rest-api/request/body/body-used.node.test.ts @@ -0,0 +1,66 @@ +/** + * @jest-environment node + */ +import { HttpResponse, http, graphql } from 'msw' +import { setupServer } from 'msw/node' +import * as express from 'express' +import { HttpServer } from '@open-draft/test-server/http' + +const httpServer = new HttpServer((app) => { + app.post('/resource', express.json(), (req, res) => { + res.json({ response: `received: ${req.body.message}` }) + }) +}) + +const server = setupServer() + +beforeAll(async () => { + server.listen() + await httpServer.listen() +}) + +afterEach(() => { + server.resetHandlers() + jest.restoreAllMocks() +}) + +afterAll(async () => { + server.close() + await httpServer.close() +}) + +it('does not read the body while parsing an unhandled request', async () => { + // Expecting an unhandled request warning in this test. + jest.spyOn(console, 'warn').mockImplementation(() => {}) + + const requestUrl = httpServer.http.url('/resource') + const response = await fetch(requestUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: 'Hello server', + }), + }) + expect(await response.json()).toEqual({ response: `received: Hello server` }) +}) + +it('does not read the body while parsing an unhandled request', async () => { + const requestUrl = httpServer.http.url('/resource') + server.use( + http.post(requestUrl, () => { + return HttpResponse.json({ mocked: true }) + }), + ) + const response = await fetch(requestUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: 'Hello server', + }), + }) + expect(await response.json()).toEqual({ mocked: true }) +}) diff --git a/test/node/rest-api/request/matching/all.node.test.ts b/test/node/rest-api/request/matching/all.node.test.ts index 4afeba4fb..350855cee 100644 --- a/test/node/rest-api/request/matching/all.node.test.ts +++ b/test/node/rest-api/request/matching/all.node.test.ts @@ -3,7 +3,7 @@ */ import fetch, { Response } from 'node-fetch' import { HttpServer } from '@open-draft/test-server/http' -import { RESTMethods, rest } from 'msw' +import { HttpMethods, http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' const httpServer = new HttpServer((app) => { @@ -31,21 +31,21 @@ afterAll(async () => { await httpServer.close() }) -async function forEachMethod(callback: (method: RESTMethods) => unknown) { - for (const method of Object.values(RESTMethods)) { +async function forEachMethod(callback: (method: HttpMethods) => unknown) { + for (const method of Object.values(HttpMethods)) { await callback(method) } } test('matches all requests given no custom path', async () => { server.use( - rest.all('*', (req, res, ctx) => { - return res(ctx.text('welcome to the jungle')) + http.all('*', () => { + return HttpResponse.text('welcome to the jungle') }), ) const responses = await Promise.all( - Object.values(RESTMethods).reduce[]>((all, method) => { + Object.values(HttpMethods).reduce[]>((all, method) => { return all.concat( [ httpServer.http.url('/'), @@ -57,22 +57,22 @@ test('matches all requests given no custom path', async () => { ) for (const response of responses) { - expect(response.status).toEqual(200) + expect(response.status).toBe(200) expect(await response.text()).toEqual('welcome to the jungle') } }) test('respects custom path when matching requests', async () => { server.use( - rest.all(httpServer.http.url('/api/*'), (req, res, ctx) => { - return res(ctx.text('hello world')) + http.all(httpServer.http.url('/api/*'), () => { + return HttpResponse.text('hello world') }), ) // Root requests. await forEachMethod(async (method) => { const response = await fetch(httpServer.http.url('/api/'), { method }) - expect(response.status).toEqual(200) + expect(response.status).toBe(200) expect(await response.text()).toEqual('hello world') }) @@ -81,7 +81,7 @@ test('respects custom path when matching requests', async () => { const response = await fetch(httpServer.http.url('/api/foo'), { method, }) - expect(response.status).toEqual(200) + expect(response.status).toBe(200) expect(await response.text()).toEqual('hello world') }) diff --git a/test/node/rest-api/request/matching/path-params-decode.node.test.ts b/test/node/rest-api/request/matching/path-params-decode.node.test.ts index eaeda027f..f59815ffc 100644 --- a/test/node/rest-api/request/matching/path-params-decode.node.test.ts +++ b/test/node/rest-api/request/matching/path-params-decode.node.test.ts @@ -2,15 +2,16 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' const server = setupServer( - rest.get('https://test.mswjs.io/reflect-url/:url', (req, res, ctx) => { - const { url } = req.params - - return res(ctx.json({ url })) - }), + http.get<{ url: string }>( + 'https://test.mswjs.io/reflect-url/:url', + ({ params }) => { + return HttpResponse.json({ url: params.url }) + }, + ), ) beforeAll(() => { @@ -28,7 +29,7 @@ test('decodes url componets', async () => { `https://test.mswjs.io/reflect-url/${encodeURIComponent(url)}`, ) - expect(res.status).toEqual(200) + expect(res.status).toBe(200) expect(await res.json()).toEqual({ url, }) diff --git a/test/node/rest-api/response/body/body-binary.node.test.ts b/test/node/rest-api/response/body-binary.node.test.ts similarity index 75% rename from test/node/rest-api/response/body/body-binary.node.test.ts rename to test/node/rest-api/response/body-binary.node.test.ts index c49427d04..1c9d8b681 100644 --- a/test/node/rest-api/response/body/body-binary.node.test.ts +++ b/test/node/rest-api/response/body-binary.node.test.ts @@ -4,24 +4,23 @@ import * as path from 'path' import * as fs from 'fs' import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' function getImageBuffer() { - return fs.readFileSync( - path.resolve(__dirname, '../../../../fixtures/image.jpg'), - ) + return fs.readFileSync(path.resolve(__dirname, '../../../fixtures/image.jpg')) } const server = setupServer( - rest.get('http://test.mswjs.io/image', (_, res, ctx) => { + http.get('http://test.mswjs.io/image', () => { const imageBuffer = getImageBuffer() - return res( - ctx.set('Content-Length', imageBuffer.byteLength.toString()), - ctx.set('Content-Type', 'image/jpeg'), - ctx.body(imageBuffer), - ) + return HttpResponse.arrayBuffer(imageBuffer, { + headers: { + 'Content-Type': 'image/jpeg', + 'Content-Length': imageBuffer.byteLength.toString(), + }, + }) }), ) @@ -35,7 +34,6 @@ test('returns given buffer in the mocked response', async () => { const expectedImageBuffer = getImageBuffer() expect(status).toBe(200) - expect(headers.get('x-powered-by')).toBe('msw') expect(headers.get('content-length')).toBe( actualImageBuffer.byteLength.toString(), ) @@ -52,7 +50,6 @@ test('returns given blob in the mocked response', async () => { const expectedImageBuffer = getImageBuffer() expect(status).toBe(200) - expect(headers.get('x-powered-by')).toBe('msw') expect(blob.type).toBe('image/jpeg') expect(blob.size).toBe(Number(headers.get('content-length'))) expect(Buffer.compare(actualImageBuffer, expectedImageBuffer)).toBe(0) diff --git a/test/node/rest-api/response/body-json.node.test.ts b/test/node/rest-api/response/body-json.node.test.ts new file mode 100644 index 000000000..9c59d1b92 --- /dev/null +++ b/test/node/rest-api/response/body-json.node.test.ts @@ -0,0 +1,36 @@ +/** + * @jest-environment node + */ +import { HttpResponse, http } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer( + http.get('http://localhost/json', () => { + return HttpResponse.json({ firstName: 'John' }) + }), + http.get('http://localhost/number', () => { + return HttpResponse.json(123) + }), +) + +beforeAll(() => { + server.listen() +}) + +afterAll(() => { + server.close() +}) + +test('responds with a JSON response body', async () => { + const response = await fetch('http://localhost/json') + + expect(response.headers.get('content-type')).toBe('application/json') + expect(await response.json()).toEqual({ firstName: 'John' }) +}) + +test('responds with a single number JSON response body', async () => { + const response = await fetch('http://localhost/number') + + expect(response.headers.get('content-type')).toBe('application/json') + expect(await response.json()).toEqual(123) +}) diff --git a/test/node/rest-api/response/body-stream.node.test.ts b/test/node/rest-api/response/body-stream.node.test.ts new file mode 100644 index 000000000..2e657b5b7 --- /dev/null +++ b/test/node/rest-api/response/body-stream.node.test.ts @@ -0,0 +1,102 @@ +/** + * @jest-environment node + */ +import https from 'https' +import { HttpResponse, http, delay } from 'msw' +import { setupServer } from 'msw/node' + +const encoder = new TextEncoder() +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +test('responds with a ReadableStream', async () => { + server.use( + http.get('https://api.example.com/stream', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('hello')) + controller.enqueue(encoder.encode('world')) + controller.close() + }, + }) + + return new HttpResponse(stream, { + headers: { + 'Content-Type': 'text/event-stream', + }, + }) + }), + ) + + const response = await fetch('https://api.example.com/stream') + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.body).toBeInstanceOf(ReadableStream) + expect(response.body!.locked).toBe(false) + + expect(await response.text()).toBe('helloworld') +}) + +test('supports delays when enqueuing chunks', (done) => { + server.use( + http.get('https://api.example.com/stream', () => { + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode('first')) + await delay(500) + + controller.enqueue(encoder.encode('second')) + await delay(500) + + controller.enqueue(encoder.encode('third')) + await delay(500) + controller.close() + }, + }) + + return new HttpResponse(stream, { + headers: { + 'Content-Type': 'text/event-stream', + }, + }) + }), + ) + + const request = https.get('https://api.example.com/stream', (response) => { + const chunks: Array<{ buffer: Buffer; timestamp: number }> = [] + + response.on('data', (data) => { + chunks.push({ + buffer: Buffer.from(data), + timestamp: Date.now(), + }) + }) + + response.once('end', () => { + const textChunks = chunks.map((chunk) => chunk.buffer.toString('utf8')) + expect(textChunks).toEqual(['first', 'second', 'third']) + + // Ensure that the chunks were sent over time, + // respecting the delay set in the mocked stream. + const chunkTimings = chunks.map((chunk) => chunk.timestamp) + expect(chunkTimings[1] - chunkTimings[0]).toBeGreaterThanOrEqual(490) + expect(chunkTimings[2] - chunkTimings[1]).toBeGreaterThanOrEqual(490) + + done() + }) + }) + + request.on('error', done) +}) diff --git a/test/node/rest-api/response/body/body-text.node.test.ts b/test/node/rest-api/response/body-text.node.test.ts similarity index 74% rename from test/node/rest-api/response/body/body-text.node.test.ts rename to test/node/rest-api/response/body-text.node.test.ts index b2b0c85e9..0c97edf49 100644 --- a/test/node/rest-api/response/body/body-text.node.test.ts +++ b/test/node/rest-api/response/body-text.node.test.ts @@ -2,12 +2,12 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' const server = setupServer( - rest.get('http://localhost/text', (req, res, ctx) => { - return res(ctx.text('hello world')) + http.get('http://localhost/text', () => { + return HttpResponse.text('hello world') }), ) @@ -23,6 +23,7 @@ test('responds with a text response body', async () => { const res = await fetch('http://localhost/text') const text = await res.text() + expect(res.status).toBe(200) expect(res.headers.get('content-type')).toBe('text/plain') expect(text).toBe('hello world') }) diff --git a/test/node/rest-api/response/body/body-xml.node.test.ts b/test/node/rest-api/response/body-xml.node.test.ts similarity index 83% rename from test/node/rest-api/response/body/body-xml.node.test.ts rename to test/node/rest-api/response/body-xml.node.test.ts index ebaef22a4..8862936db 100644 --- a/test/node/rest-api/response/body/body-xml.node.test.ts +++ b/test/node/rest-api/response/body-xml.node.test.ts @@ -2,19 +2,17 @@ * @jest-environment node */ import fetch from 'node-fetch' -import { rest } from 'msw' +import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' const server = setupServer( - rest.get('http://localhost/xml', (req, res, ctx) => { - return res( - ctx.xml(` + http.get('http://localhost/xml', () => { + return HttpResponse.xml(` abc-123 John Maverick -`), - ) +`) }), ) diff --git a/test/node/rest-api/response/body/body-json.node.test.ts b/test/node/rest-api/response/body/body-json.node.test.ts deleted file mode 100644 index bb60182a9..000000000 --- a/test/node/rest-api/response/body/body-json.node.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @jest-environment node - */ -import fetch from 'node-fetch' -import { rest } from 'msw' -import { setupServer } from 'msw/node' - -const server = setupServer( - rest.get('http://localhost/json', (req, res, ctx) => { - return res(ctx.json({ firstName: 'John' })) - }), - rest.get('http://localhost/number', (req, res, ctx) => { - return res(ctx.json(123)) - }), -) - -beforeAll(() => { - server.listen() -}) - -afterAll(() => { - server.close() -}) - -test('responds with a JSON response body', async () => { - const res = await fetch('http://localhost/json') - - expect(res.headers.get('content-type')).toBe('application/json') - - const json = await res.json() - expect(json).toEqual({ firstName: 'John' }) -}) - -test('responds with a single number JSON response body', async () => { - const res = await fetch('http://localhost/number') - - expect(res.headers.get('content-type')).toBe('application/json') - - const json = await res.json() - expect(json).toEqual(123) -}) diff --git a/test/node/rest-api/response/response-error.test.ts b/test/node/rest-api/response/response-error.test.ts new file mode 100644 index 000000000..443023ecb --- /dev/null +++ b/test/node/rest-api/response/response-error.test.ts @@ -0,0 +1,35 @@ +/** + * @jest-environment node + */ +import { http } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +it('responds with a mocked error response using "Response.error" shorthand', async () => { + server.use( + http.get('https://api.example.com/resource', () => { + return Response.error() + }), + ) + + const responseError = await fetch('https://api.example.com/resource') + .then(() => null) + .catch((error) => error) + + expect(responseError).toEqual(new TypeError('Failed to fetch')) + // Guard against false positives due to exceptions arising from the library. + expect(responseError.cause).toEqual(Response.error()) +}) diff --git a/test/support/graphql.ts b/test/support/graphql.ts index 766e47c5d..37de7c55f 100644 --- a/test/support/graphql.ts +++ b/test/support/graphql.ts @@ -10,7 +10,7 @@ export const gql = (str: TemplateStringsArray) => { interface GraphQLClientOPtions { uri: string - fetch?: typeof fetch + fetch?: (input: any, init?: any) => Promise } interface GraphQLOperationInput { @@ -25,8 +25,10 @@ interface GraphQLOperationInput { export function createGraphQLClient(options: GraphQLClientOPtions) { const fetchFn = options.fetch || fetch - return async (input: GraphQLOperationInput): Promise => { - const res = await fetchFn(options.uri, { + return async >( + input: GraphQLOperationInput, + ): Promise> => { + const response = await fetchFn(options.uri, { method: 'POST', headers: { accept: '*/*', @@ -38,6 +40,6 @@ export function createGraphQLClient(options: GraphQLClientOPtions) { // No need to transform the JSON into `ExecutionResult`, // because that's the responsibility of an actual server // or an MSW request handler. - return res.json() + return response.json() } } diff --git a/test/typings/graphql.test-d.ts b/test/typings/graphql.test-d.ts index 93685fae7..488b4e467 100644 --- a/test/typings/graphql.test-d.ts +++ b/test/typings/graphql.test-d.ts @@ -1,63 +1,111 @@ import { parse } from 'graphql' -import { - MockedRequest, - GraphQLRequest, - graphql, - GraphQLHandler, - GraphQLVariables, -} from 'msw' - -graphql.query<{ key: string }>('', (req, res, ctx) => { - return res( - ctx.data( - // @ts-expect-error Response data doesn't match the query type. - {}, - ), - ) +import { graphql, HttpResponse } from 'msw' + +/** + * Variables type. + */ +graphql.mutation('CreateUser', ({ variables }) => { + variables.id + variables.unknown +}) + +graphql.mutation('CreateUser', ({ variables }) => { + variables.id.toUpperCase() + // @ts-expect-error unknown variable name + variables.unknown +}) + +graphql.mutation('CreateUser', ({ variables }) => { + // @ts-expect-error + variables.id.toUpperCase() + // @ts-expect-error + variables.unknown }) graphql.query< { key: string }, // @ts-expect-error `null` is not a valid variables type. null ->('', (req, res, ctx) => { - return res(ctx.data({ key: 'pass' })) -}) - -graphql.mutation<{ key: string }>('', (req, res, ctx) => - res( - ctx.data( - // @ts-expect-error Response data doesn't match the query type. - {}, - ), - ), -) +>('', () => {}) graphql.mutation< { key: string }, // @ts-expect-error `null` is not a valid variables type. null ->('', (req, res, ctx) => { - return res(ctx.data({ key: 'pass' })) -}) - -graphql.operation<{ key: string }>((req, res, ctx) => { - return res( - ctx.data( - // @ts-expect-error Response data doesn't match the query type. - {}, - ), - ) -}) +>('', () => {}) graphql.operation< { key: string }, // @ts-expect-error `null` is not a valid variables type. null ->((req, res, ctx) => { - return res(ctx.data({ key: 'pass' })) +>(() => { + return HttpResponse.json({ data: { key: 'a' } }) }) +/** + * Response body type (GraphQL query type). + */ +// Returned mocked response body must satisfy the +// GraphQL query generic. +graphql.query<{ id: string }>('GetUser', () => { + return HttpResponse.json({ + data: { id: '2' }, + }) +}) + +graphql.query<{ id: string }>( + 'GetUser', + // @ts-expect-error "id" type is incorrect + () => { + return HttpResponse.json({ + data: { id: 123 }, + }) + }, +) + +graphql.query<{ id: string }>( + 'GetUser', + // @ts-expect-error response json is empty + () => HttpResponse.json({ data: {} }), +) + +graphql.query<{ id: string }>( + 'GetUser', + // @ts-expect-error incompatible response body type + () => HttpResponse.text('hello'), +) + +/// +/// +/// + +graphql.query<{ key: string }>( + 'GetData', + // @ts-expect-error Response data doesn't match the query type. + () => { + return HttpResponse.json({ data: {} }) + }, +) + +graphql.mutation<{ key: string }>( + 'MutateData', + // @ts-expect-error Response data doesn't match the query type. + () => { + return HttpResponse.json({ data: {} }) + }, +) + +graphql.operation<{ key: string }>( + // @ts-expect-error Response data doesn't match the query type. + () => { + return HttpResponse.json({ data: {} }) + }, +) + +/** + * Variables type. + */ + /** * Supports `DocumentNode` as the GraphQL operation name. */ @@ -68,15 +116,15 @@ const getUser = parse(` } } `) -graphql.query(getUser, (req, res, ctx) => - res( - ctx.data({ - // Cannot extract query type from the runtime `DocumentNode`. - arbitrary: true, - }), - ), -) +graphql.query(getUser, () => { + return HttpResponse.json({ + // Cannot extract query type from the runtime `DocumentNode`. + data: { arbitrary: true }, + }) +}) +// Both variable and response types can be extracted +// from a "TypedDocumentNode" value. const getUserById = parse(` query GetUserById($userId: String!) { user(id: $userId) { @@ -84,21 +132,21 @@ const getUserById = parse(` } } `) -graphql.query(getUserById, (req, res, ctx) => { - req.variables.userId +graphql.query(getUserById, ({ variables }) => { + variables.userId.toUpperCase() // Extracting variables from the native "DocumentNode" is impossible. - req.variables.foo + variables.foo - return res( - ctx.data({ + return HttpResponse.json({ + data: { user: { firstName: 'John', // Extracting a query body type from the "DocumentNode" is impossible. lastName: 'Maverick', }, - }), - ) + }, + }) }) const createUser = parse(` @@ -108,28 +156,8 @@ const createUser = parse(` } } `) -graphql.mutation(createUser, (req, res, ctx) => - res( - ctx.data({ - arbitrary: true, - }), - ), -) - -// GraphQL request variables must be inferrable -// via the variables generic. -function extractVariables( - _handler: GraphQLHandler>, -): MockedRequest> { - return null as any -} -const handlerWithVariables = graphql.query<{ data: unknown }, { id: string }>( - 'GetUser', - () => void 0, -) -const handler = extractVariables(handlerWithVariables) - -handler.body.variables.id - -// @ts-expect-error Property "foo" is not defined on the variables generic. -handler.body.variables.foo +graphql.mutation(createUser, () => { + return HttpResponse.json({ + data: { arbitrary: true }, + }) +}) diff --git a/test/typings/path-params.test-d.ts b/test/typings/path-params.test-d.ts deleted file mode 100644 index 60749efe4..000000000 --- a/test/typings/path-params.test-d.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { rest } from 'msw' - -rest.get('/user/:userId', (req) => { - req.params.userId - - // @ts-expect-error `unknown` is not defined in the request params type. - req.params.unknown -}) - -rest.get('/user/:id', (req, res, ctx) => { - const { userId } = req.params - - return res( - ctx.body( - // @ts-expect-error "userId" parameter is not annotated - // and is ambiguous (string | string[]). - userId, - ), - ) -}) - -rest.get< - never, - // @ts-expect-error Path parameters are always strings. - // Parse them to numbers in the resolver if necessary. - { id: number } ->('/posts/:id', () => null) - -/** - * Using interface as path parameters type. - */ -interface UserParamsInterface { - userId: string -} - -rest.get('/user/:userId', (req) => { - req.params.userId.toUpperCase() - - // @ts-expect-error Unknown path parameter "foo". - req.params.foo -}) - -/** - * Using type as path parameters type. - */ -type UserParamsType = { - userId: string -} - -rest.get('/user/:userId', (req) => { - req.params.userId.toUpperCase() - - // @ts-expect-error Unknown path parameter "foo". - req.params.foo -}) diff --git a/test/typings/rest.test-d.ts b/test/typings/rest.test-d.ts index dabe4dd68..7b7df59a7 100644 --- a/test/typings/rest.test-d.ts +++ b/test/typings/rest.test-d.ts @@ -1,61 +1,121 @@ -import { rest } from 'msw' +import { http, HttpResponse } from 'msw' -rest.get('/user', (req, res, ctx) => { - // @ts-expect-error `session` property is not defined on the request body type. - req.body.session +/** + * Request path parameters. + */ +http.get<{ id: string }>('/user/:id', ({ params }) => { + params.id.toUpperCase() - res( - // @ts-expect-error JSON doesn't match given response body generic type. - ctx.json({ unknown: true }), - ) + // @ts-expect-error Unknown path parameter + params.unknown +}) - res( - // @ts-expect-error value types do not match - ctx.json({ postCount: 'this is not a number' }), - ) +http.get<{ a: string; b: string[] }>('/user/:a/:b/:b', ({ params }) => { + params.a.toUpperCase() + params.b.map((x) => x) - return res(ctx.json({ postCount: 2 })) + // @ts-expect-error Unknown path parameter + params.unknown }) -rest.post('/submit', () => null) +// Supports path parameters declaration via type. +type UserPathParams = { id: string } +http.get('/user/:id', ({ params }) => { + params.id.toUpperCase() -rest.get< - any, - // @ts-expect-error `null` is not a valid response body type. - null ->('/user', () => null) + // @ts-expect-error Unknown path parameter + params.unknown +}) -rest.get('/user', (req, res, ctx) => - // allow ResponseTransformer to contain a more specific type - res(ctx.json({ label: true })), -) +// Supports path parameters declaration via interface. +interface PostPathParameters { + id: string +} +http.get('/user/:id', ({ params }) => { + params.id.toUpperCase() -rest.get('/user', (req, res, ctx) => - // allow ResponseTransformer to return a narrower type than a given union - res(ctx.json('hello')), -) + // @ts-expect-error Unknown path parameter + params.unknown +}) -rest.head('/user', (req) => { - // @ts-expect-error GET requests cannot have body. - req.body.toString() +http.get('/user/:a/:b', ({ params }) => { + // @ts-expect-error Unknown path parameter + params.a.toUpperCase() + // @ts-expect-error Unknown path parameter + params.b.map((x) => x) }) -rest.head('/user', (req) => { - // @ts-expect-error GET requests cannot have body. - req.body.toString() +/** + * Request body generic. + */ +http.post('/user', async ({ request }) => { + const data = await request.json() + data.id + + // @ts-expect-error Unknown property + data.unknown + + const text = await request.text() + text.toUpperCase() + // @ts-expect-error Text remains plain text. + text.id }) -rest.get('/user', (req) => { - // @ts-expect-error GET requests cannot have body. - req.body.toString() +http.get('/user', async ({ request }) => { + const data = await request.json() + // @ts-expect-error Null is not an object + Object.keys(data) }) -rest.get('/user', (req) => { - // @ts-expect-error GET requests cannot have body. - req.body.toString() +/** + * Response body generic. + */ +http.get('/user', () => { + // Allows responding with a plain Response + // when no response body generic is set. + return new Response('hello') }) -rest.post<{ userId: string }>('/user', (req) => { - req.body.userId.toUpperCase() +http.get('/user', () => { + return HttpResponse.json({ id: 1 }) }) + +// Supports explicit response data declared via type. +type ResponseBodyType = { id: number } +http.get('/user', () => { + const data: ResponseBodyType = { id: 1 } + return HttpResponse.json(data) +}) + +// Supports explicit response data declared via interface. +interface ResponseBodyInterface { + id: number +} +http.get('/user', () => { + const data: ResponseBodyInterface = { id: 1 } + return HttpResponse.json(data) +}) + +http.get( + '/user', + // @ts-expect-error String not assignable to number + () => HttpResponse.json({ id: 'invalid' }), +) + +http.get( + '/user', + // @ts-expect-error Missing property "id" + () => HttpResponse.json({}), +) + +// Response resolver can return a response body of a +// narrower type than defined in the generic. +http.get('/user', () => + HttpResponse.json(['value']), +) + +// Response resolver can return a more specific type +// than provided in the response generic. +http.get('/user', () => + HttpResponse.json({ label: true }), +) diff --git a/test/typings/run.ts b/test/typings/run.ts index 86b26d7bd..fe7861751 100644 --- a/test/typings/run.ts +++ b/test/typings/run.ts @@ -2,7 +2,7 @@ import * as fs from 'fs' import * as path from 'path' import { spawnSync } from 'child_process' import { invariant } from 'outvariant' -import tsPackageJson from 'typescript/package.json' +import * as tsPackageJson from 'typescript/package.json' const tsInstalledVersion = tsPackageJson.version invariant( diff --git a/test/typings/set.test-d.ts b/test/typings/set.test-d.ts deleted file mode 100644 index ecc6b9bde..000000000 --- a/test/typings/set.test-d.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { defaultContext } from 'msw' - -const { set } = defaultContext - -set('header', 'value') -set({ - one: 'value', - two: 'value', -}) - -// @ts-expect-error Forbidden response header. -set('cookie', 'secret') -// @ts-expect-error Forbidden response header. -set('Cookie', 'secret') -// @ts-expect-error Forbidden response header. -set('cookie2', 'secret') -// @ts-expect-error Forbidden response header. -set('Cookie2', 'secret') -// @ts-expect-error Forbidden response header. -set('set-cookie', 'secret') -// @ts-expect-error Forbidden response header. -set('Set-Cookie', 'secret') -// @ts-expect-error Forbidden response header. -set('set-cookie2', 'secret') -// @ts-expect-error Forbidden response header. -set('Set-Cookie2', 'secret') - -// @ts-expect-error Forbidden response header. -set({ cookie: 'secret' }) -// @ts-expect-error Forbidden response header. -set({ Cookie: 'secret' }) -// @ts-expect-error Forbidden response header. -set({ cookie2: 'secret' }) -// @ts-expect-error Forbidden response header. -set({ Cookie2: 'secret' }) -// @ts-expect-error Forbidden response header. -set({ 'set-cookie': 'secret' }) -// @ts-expect-error Forbidden response header. -set({ 'Set-Cookie': 'secret' }) -// @ts-expect-error Forbidden response header. -set({ 'set-cookie2': 'secret' }) -// @ts-expect-error Forbidden response header. -set({ 'Set-Cookie2': 'secret' }) diff --git a/test/typings/tsconfig.json b/test/typings/tsconfig.json index d149b93ad..ce1fa9209 100644 --- a/test/typings/tsconfig.json +++ b/test/typings/tsconfig.json @@ -1,15 +1,13 @@ { + "extends": "../../tsconfig", "compilerOptions": { - "strict": true, "target": "esnext", "module": "commonjs", "noEmit": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "moduleResolution": "Node", "types": ["node"], "typeRoots": ["../../node_modules/@types"], "lib": ["dom"], + "rootDir": "../..", "baseUrl": ".", "paths": { "msw": ["../.."], diff --git a/tsconfig.json b/tsconfig.json index fcf4b37ed..c5e2f59a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,13 +4,18 @@ "target": "es6", "module": "ESNext", "moduleResolution": "node", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, + "allowSyntheticDefaultImports": false, + "esModuleInterop": false, "resolveJsonModule": true, "declaration": true, "declarationDir": "lib/types", "noEmit": true, - "lib": ["es2017", "ESNext.AsyncIterable", "dom", "webworker"] + "lib": ["DOM", "DOM.Iterable", "ESNext.AsyncIterable"], + "baseUrl": "./src", + "paths": { + "~/core": ["./core"], + "~/core/*": ["./core/*"] + } }, "include": ["global.d.ts", "src/**/*.ts"], "exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"] diff --git a/tsup.config.ts b/tsup.config.ts index 685e77872..609045a35 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,76 +1,112 @@ -import { defineConfig } from 'tsup' -import { workerScriptPlugin } from './config/plugins/esbuild/workerScriptPlugin' +import { defineConfig, Options } from 'tsup' +import * as glob from 'glob' +import { + getWorkerChecksum, + copyWorkerPlugin, +} from './config/plugins/esbuild/copyWorkerPlugin' +import { resolveCoreImportsPlugin } from './config/plugins/esbuild/resolveCoreImportsPlugin' +import { forceEsmExtensionsPlugin } from './config/plugins/esbuild/forceEsmExtensionsPlugin' -// Prevent from bunlding the "@mswjs/*" packages -// so that the users get the latest versions without -// having to bump them in "msw'." +// Externalize the in-house dependencies so that the user +// would get the latest published version automatically. const ecosystemDependencies = /^@mswjs\/(.+)$/ -export default defineConfig([ - { - name: 'main', - entry: ['./src/index.ts'], - outDir: './lib', - format: ['cjs'], - legacyOutput: true, - sourcemap: true, - clean: true, - bundle: true, - splitting: false, - dts: false, - esbuildPlugins: [workerScriptPlugin()], - }, - { - name: 'iife', - entry: ['./src/index.ts'], - outDir: './lib', - legacyOutput: true, - format: ['iife'], - platform: 'browser', - globalName: 'MockServiceWorker', - bundle: true, - sourcemap: true, - splitting: false, - dts: false, - esbuildPlugins: [workerScriptPlugin()], - }, - { - name: 'node', - entry: ['./src/node/index.ts'], - format: ['esm', 'cjs'], - outDir: './lib/node', - platform: 'node', - external: [ - 'http', - 'https', - 'util', - 'events', - 'tty', - 'os', - 'timers', - ecosystemDependencies, - ], - clean: true, - inject: ['./config/polyfills-node.ts'], - sourcemap: true, - dts: false, - esbuildPlugins: [workerScriptPlugin()], - }, - { - name: 'native', - entry: ['./src/native/index.ts'], - format: ['esm', 'cjs'], - outDir: './lib/native', - clean: true, - external: ['chalk', 'util', 'events', ecosystemDependencies], +// Externalize the core functionality (reused across environments) +// so that it can be shared between the environments. +const mswCore = /\/core(\/.+)?$/ + +const SERVICE_WORKER_CHECKSUM = getWorkerChecksum() + +const coreConfig: Options = { + name: 'core', + platform: 'neutral', + entry: glob.sync('./src/core/**/*.ts', { + ignore: '**/*.test.ts', + }), + external: [ecosystemDependencies], + format: ['esm', 'cjs'], + outDir: './lib/core', + bundle: false, + splitting: false, + dts: true, + esbuildPlugins: [forceEsmExtensionsPlugin()], +} + +const nodeConfig: Options = { + name: 'node', + platform: 'node', + entry: ['./src/node/index.ts'], + inject: ['./config/polyfills-node.ts'], + external: [mswCore, ecosystemDependencies], + format: ['esm', 'cjs'], + outDir: './lib/node', + sourcemap: false, + bundle: true, + splitting: false, + dts: true, + + esbuildPlugins: [resolveCoreImportsPlugin(), forceEsmExtensionsPlugin()], +} + +const browserConfig: Options = { + name: 'browser', + platform: 'browser', + entry: ['./src/browser/index.ts'], + external: [mswCore, ecosystemDependencies], + format: ['esm', 'cjs'], + outDir: './lib/browser', + bundle: true, + splitting: false, + dts: true, + define: { + SERVICE_WORKER_CHECKSUM: JSON.stringify(SERVICE_WORKER_CHECKSUM), }, - { - name: 'typedefs', - entry: ['./src/index.ts', './src/node/index.ts', './src/native/index.ts'], - outDir: './lib', - clean: false, - dts: { - only: true, - }, + esbuildPlugins: [ + resolveCoreImportsPlugin(), + forceEsmExtensionsPlugin(), + copyWorkerPlugin(SERVICE_WORKER_CHECKSUM), + ], +} + +const reactNativeConfig: Options = { + name: 'react-native', + platform: 'node', + entry: ['./src/native/index.ts'], + external: ['chalk', 'util', 'events', mswCore, ecosystemDependencies], + format: ['esm', 'cjs'], + outDir: './lib/native', + bundle: true, + splitting: false, + dts: true, + esbuildPlugins: [resolveCoreImportsPlugin(), forceEsmExtensionsPlugin()], +} + +const iifeConfig: Options = { + name: 'iife', + platform: 'browser', + globalName: 'MockServiceWorker', + entry: ['./src/iife/index.ts'], + /** + * @note Legacy output format will automatically create + * a "iife" directory under the "outDir". + */ + outDir: './lib', + format: ['iife'], + legacyOutput: true, + bundle: true, + splitting: false, + dts: false, + define: { + // Sign the IIFE build as well because any bundle containing + // the worker API must have the the integrity checksum defined. + SERVICE_WORKER_CHECKSUM: JSON.stringify(SERVICE_WORKER_CHECKSUM), }, +} + +export default defineConfig([ + coreConfig, + nodeConfig, + reactNativeConfig, + browserConfig, + iifeConfig, ])