diff --git a/.storybook/main.ts b/.storybook/main.ts index 3e673a19e..adca5a16b 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -3,6 +3,7 @@ import { StorybookConfig } from '@storybook/react-webpack5'; import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'; import { Configuration } from 'webpack'; import postcss from 'postcss'; + const config: StorybookConfig = { stories: ['../app/**/*.stories.@(ts|tsx)'], addons: [ @@ -87,9 +88,6 @@ const config: StorybookConfig = { name: '@storybook/react-webpack5', options: {}, }, - docs: { - autodocs: true, - }, }; export default config; diff --git a/.storybook/preview.js b/.storybook/preview.tsx similarity index 70% rename from .storybook/preview.js rename to .storybook/preview.tsx index 0e3e673af..65bb3754c 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.tsx @@ -1,23 +1,26 @@ import '../app/assets/css'; - +import React from 'react'; import { pushStateLocationPlugin, UIRouter } from '@uirouter/react'; -import { initialize as initMSW, mswDecorator } from 'msw-storybook-addon'; -import { handlers } from '@/setup-tests/server-handlers'; +import { initialize as initMSW, mswLoader } from 'msw-storybook-addon'; +import { handlers } from '../app/setup-tests/server-handlers'; import { QueryClient, QueryClientProvider } from 'react-query'; -// Initialize MSW -initMSW({ - onUnhandledRequest: ({ method, url }) => { - if (url.pathname.startsWith('/api')) { - console.error(`Unhandled ${method} request to ${url}. +initMSW( + { + onUnhandledRequest: ({ method, url }) => { + console.log(method, url); + if (url.startsWith('/api')) { + console.error(`Unhandled ${method} request to ${url}. This exception has been only logged in the console, however, it's strongly recommended to resolve this error as you don't want unmocked data in Storybook stories. If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses `); - } + } + }, }, -}); + handlers +); export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, @@ -44,5 +47,6 @@ export const decorators = [ ), - mswDecorator, ]; + +export const loaders = [mswLoader]; diff --git a/.storybook/public/mockServiceWorker.js b/.storybook/public/mockServiceWorker.js index a435cfa78..aaff631f3 100644 --- a/.storybook/public/mockServiceWorker.js +++ b/.storybook/public/mockServiceWorker.js @@ -2,22 +2,22 @@ /* tslint:disable */ /** - * Mock Service Worker (0.36.3). + * Mock Service Worker (2.0.11). * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. */ -const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929'; -const bypassHeaderName = 'x-msw-bypass'; +const INTEGRITY_CHECKSUM = 'c5f7f8e188b673ea4e677df7ea3c5a39'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); const activeClientIds = new Set(); self.addEventListener('install', function () { - return self.skipWaiting(); + self.skipWaiting(); }); -self.addEventListener('activate', async function (event) { - return self.clients.claim(); +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); }); self.addEventListener('message', async function (event) { @@ -33,7 +33,9 @@ self.addEventListener('message', async function (event) { return; } - const allClients = await self.clients.matchAll(); + const allClients = await self.clients.matchAll({ + type: 'window', + }); switch (event.data) { case 'KEEPALIVE_REQUEST': { @@ -83,18 +85,79 @@ self.addEventListener('message', async function (event) { } }); -// Resolve the "main" client for the given event. +self.addEventListener('fetch', function (event) { + const { request } = event; + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId)); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body] + ); + })(); + } + + return response; +} + +// Resolve the main client for the given event. // Client that issues a request doesn't necessarily equal the client // that registered the worker. It's with the latter the worker should // communicate with during the response resolving phase. async function resolveMainClient(event) { const client = await self.clients.get(event.clientId); - if (client.frameType === 'top-level') { + if (client?.frameType === 'top-level') { return client; } - const allClients = await self.clients.matchAll(); + const allClients = await self.clients.matchAll({ + type: 'window', + }); return allClients .filter((client) => { @@ -108,43 +171,27 @@ async function resolveMainClient(event) { }); } -async function handleRequest(event, requestId) { - const client = await resolveMainClient(event); - const response = await getResponse(event, client, requestId); - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // 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: serializeHeaders(clonedResponse.headers), - redirected: clonedResponse.redirected, - }, - }); - })(); - } - - return response; -} - async function getResponse(event, client, requestId) { const { request } = event; + + // 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(); - const getOriginalResponse = () => fetch(requestClone); - // Bypass mocking when the request client is not active. + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()); + + // 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(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. if (!client) { - return getOriginalResponse(); + return passthrough(); } // Bypass initial page load requests (i.e. static assets). @@ -152,145 +199,56 @@ async function getResponse(event, client, requestId) { // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet // and is not ready to handle requests. if (!activeClientIds.has(client.id)) { - return await getOriginalResponse(); + return passthrough(); } - // Bypass requests with the explicit bypass header - if (requestClone.headers.get(bypassHeaderName) === 'true') { - const cleanRequestHeaders = serializeHeaders(requestClone.headers); - - // Remove the bypass header to comply with the CORS preflight check. - delete cleanRequestHeaders[bypassHeaderName]; - - const originalRequest = new Request(requestClone, { - headers: new Headers(cleanRequestHeaders), - }); - - return fetch(originalRequest); + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + const mswIntention = request.headers.get('x-msw-intention'); + if (['bypass', 'passthrough'].includes(mswIntention)) { + return passthrough(); } - // Send the request to the client-side MSW. - const reqHeaders = serializeHeaders(request.headers); - const body = await request.text(); - - const clientMessage = await sendToClient(client, { - type: 'REQUEST', - payload: { - id: requestId, - url: request.url, - method: request.method, - headers: reqHeaders, - 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, - bodyUsed: request.bodyUsed, - keepalive: request.keepalive, + // Notify the client that a request has been intercepted. + 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_SUCCESS': { - return delayPromise(() => respondWithMock(clientMessage), clientMessage.payload.delay); + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); } case 'MOCK_NOT_FOUND': { - return getOriginalResponse(); - } - - case 'NETWORK_ERROR': { - const { name, message } = clientMessage.payload; - const networkError = new Error(message); - networkError.name = name; - - // Rejecting a request Promise emulates a network error. - throw networkError; - } - - case 'INTERNAL_ERROR': { - const parsedBody = JSON.parse(clientMessage.payload.body); - - console.error( - `\ -[MSW] Uncaught exception in the request handler for "%s %s": - -${parsedBody.location} - -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 - ); - - return respondWithMock(clientMessage); + return passthrough(); } } - return getOriginalResponse(); + return passthrough(); } -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') { - return; - } - - // Opening the DevTools triggers the "only-if-cached" request - // that cannot be handled by the worker. Bypass such requests. - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { - return; - } - - // Bypass all requests when there are no active clients. - // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). - if (activeClientIds.size === 0) { - return; - } - - const requestId = uuidv4(); - - return 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}` - ); - }) - ); -}); - -function serializeHeaders(headers) { - const reqHeaders = {}; - headers.forEach((value, name) => { - reqHeaders[name] = reqHeaders[name] ? [].concat(reqHeaders[name]).concat(value) : value; - }); - return reqHeaders; -} - -function sendToClient(client, message) { +function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { const channel = new MessageChannel(); @@ -302,27 +260,25 @@ function sendToClient(client, message) { resolve(event.data); }; - client.postMessage(JSON.stringify(message), [channel.port2]); + client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean))); }); } -function delayPromise(cb, duration) { - return new Promise((resolve) => { - setTimeout(() => resolve(cb()), duration); - }); -} +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(); + } -function respondWithMock(clientMessage) { - return new Response(clientMessage.payload.body, { - ...clientMessage.payload, - headers: clientMessage.payload.headers, - }); -} + const mockedResponse = new Response(response.body, response); -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); + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, }); + + return mockedResponse; } diff --git a/package.json b/package.json index ad8eca738..906d0fc03 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,6 @@ "lucide-react": "^0.101.0", "moment": "^2.29.1", "moment-timezone": "^0.5.40", - "msw": "^2.0.11", "mustache": "^4.2.0", "ng-file-upload": "~12.2.13", "parse-duration": "^1.0.2", @@ -190,7 +189,8 @@ "lint-staged": "^14.0.1", "lodash-webpack-plugin": "^0.11.6", "mini-css-extract-plugin": "^2.7.6", - "msw-storybook-addon": "^1.8.0", + "msw": "^2.0.11", + "msw-storybook-addon": "2.0.0--canary.122.b3ed3b1.0", "ngtemplate-loader": "^2.1.0", "plop": "^4.0.0", "postcss": "^8.4.33", @@ -231,5 +231,8 @@ "**/moment": "^2.21.0", "msw/**/wrap-ansi": "^7.0.0" }, - "browserslist": "last 2 versions" + "browserslist": "last 2 versions", + "msw": { + "workerDirectory": ".storybook/public" + } } diff --git a/tsconfig.json b/tsconfig.json index 53df86660..2d85a8839 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,5 +41,5 @@ "types": ["vitest/globals"] }, "exclude": ["api", "build", "dist", "distribution", "node_modules", "test", "webpack"], - "include": ["app"] + "include": ["app", ".storybook"] } diff --git a/yarn.lock b/yarn.lock index 3fb21edc6..08f2edb49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9274,14 +9274,6 @@ endent@^2.0.1: fast-json-parse "^1.0.3" objectorarray "^1.0.5" -enhanced-resolve@^5.14.1: - version "5.14.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.14.1.tgz#de684b6803724477a4af5d74ccae5de52c25f6b3" - integrity sha512-Vklwq2vDKtl0y/vtwjSesgJ5MYS7Etuk5txS8VdKL4AOS1aUlD96zqIfsOSLQsdv3xgMRbtkWM8eG9XDfKUPow== - dependencies: - graceful-fs "^4.2.4" - tapable "^2.2.0" - enhanced-resolve@^5.15.0: version "5.15.0" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz#1af946c7d93603eb88e9896cee4904dc012e9c35" @@ -12855,10 +12847,10 @@ ms@2.1.3, ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -msw-storybook-addon@^1.8.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/msw-storybook-addon/-/msw-storybook-addon-1.10.0.tgz#8855cb0171ed2ac0649b1ce386e0e0d67420cfd4" - integrity sha512-soCTMTf7DnLeaMnFHPrtVgbyeFTJALVvnDHpzzXpJad+HOzJgQdwU4EAzVfDs1q+X5cVEgxOdAhSMC7ljvnSXg== +msw-storybook-addon@2.0.0--canary.122.b3ed3b1.0: + version "2.0.0--canary.122.b3ed3b1.0" + resolved "https://registry.yarnpkg.com/msw-storybook-addon/-/msw-storybook-addon-2.0.0--canary.122.b3ed3b1.0.tgz#4cce8652882f10819bde3d10da5d2a58308a043a" + integrity sha512-HZn9B6MCdfHpgm5wQ92A0K3moXDCDTxPRjWWH6C/4myg3KmsD0kwiaE14vWO49A4+TG10yXMGqIll2NjIMtnQg== dependencies: is-node-process "^1.0.1" @@ -15269,7 +15261,7 @@ scheduler@^0.20.2: loose-envify "^1.1.0" object-assign "^4.1.1" -schema-utils@^3.0.0, schema-utils@^3.1.1, schema-utils@^3.1.2: +schema-utils@^3.0.0, schema-utils@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.2.tgz#36c10abca6f7577aeae136c804b0c741edeadc99" integrity sha512-pvjEHOgWc9OWA/f/DE3ohBWTD6EleVLf7iFUkoSwAxttdBhB9QUebQgxER2kWueOvRJXPHNnyrvvh9eZINB8Eg== @@ -17234,37 +17226,7 @@ webpack-virtual-modules@^0.6.1: resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.1.tgz#ac6fdb9c5adb8caecd82ec241c9631b7a3681b6f" integrity sha512-poXpCylU7ExuvZK8z+On3kX+S8o/2dQ/SVYueKA0D4WEMXROXgY8Ez50/bQEUmvoSMMrWcrJqCHuhAbsiwg7Dg== -webpack@5: - version "5.84.1" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.84.1.tgz#d4493acdeca46b26ffc99d86d784cabfeb925a15" - integrity sha512-ZP4qaZ7vVn/K8WN/p990SGATmrL1qg4heP/MrVneczYtpDGJWlrgZv55vxaV2ul885Kz+25MP2kSXkPe3LZfmg== - dependencies: - "@types/eslint-scope" "^3.7.3" - "@types/estree" "^1.0.0" - "@webassemblyjs/ast" "^1.11.5" - "@webassemblyjs/wasm-edit" "^1.11.5" - "@webassemblyjs/wasm-parser" "^1.11.5" - acorn "^8.7.1" - acorn-import-assertions "^1.9.0" - browserslist "^4.14.5" - chrome-trace-event "^1.0.2" - enhanced-resolve "^5.14.1" - es-module-lexer "^1.2.1" - eslint-scope "5.1.1" - events "^3.2.0" - glob-to-regexp "^0.4.1" - graceful-fs "^4.2.9" - json-parse-even-better-errors "^2.3.1" - loader-runner "^4.2.0" - mime-types "^2.1.27" - neo-async "^2.6.2" - schema-utils "^3.1.2" - tapable "^2.1.1" - terser-webpack-plugin "^5.3.7" - watchpack "^2.4.0" - webpack-sources "^3.2.3" - -webpack@^5.88.2: +webpack@5, webpack@^5.88.2: version "5.88.2" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.88.2.tgz#f62b4b842f1c6ff580f3fcb2ed4f0b579f4c210e" integrity sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==