From e80bb1adea9e7debfe0373c9bd7433c1d4a3604c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 17 Apr 2024 13:14:42 +0200 Subject: [PATCH 1/9] fix: node export condition in @mswjs/interceptors --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 66849b212..5aa62053a 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.26.14", + "@mswjs/interceptors": "^0.28.1", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42d53c560..6de1f9f57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^1.1.0 version: 1.1.0 '@mswjs/interceptors': - specifier: ^0.26.14 - version: 0.26.15 + specifier: ^0.28.1 + version: 0.28.1 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -1410,8 +1410,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors@0.26.15: - resolution: {integrity: sha512-HM47Lu1YFmnYHKMBynFfjCp0U/yRskHj/8QEJW0CBEPOlw8Gkmjfll+S9b8M7V5CNDw2/ciRxjjnWeaCiblSIQ==} + /@mswjs/interceptors@0.28.1: + resolution: {integrity: sha512-wveXxNJ/GwpWzckD/2vU1o24+Ib97kEfc/luiP1YvF0dSziE+VhsGh2uqysR7pLZGWv22gKNs0nJGboEY+yBtw==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 00eef04c53bf83d024f84bff8c1a438b35fbc706 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 17 Apr 2024 14:43:46 +0200 Subject: [PATCH 2/9] test: adjust tests --- .../callback-throws.node.test.ts | 24 +++++++------------ .../response/throw-response.node.test.ts | 19 ++++++++------- 2 files changed, 19 insertions(+), 24 deletions(-) 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 aff450f17..507587b04 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 @@ -27,20 +27,14 @@ afterAll(() => { server.close() }) -test('prevents a request when a custom callback throws an exception', async () => { - const makeRequest = () => { - return fetch('https://example.com') - .then(() => { - throw new Error('Must not resolve') - }) - .catch((error) => error) - } +test('handles exceptions in "onUnhandledRequest" callback as 500 responses', async () => { + const response = await fetch('https://example.com') - const requestError = await makeRequest() - - // Request should be cancelled with a fetch error, since the callback threw. - expect(requestError.message).toBe('Failed to fetch') - expect(requestError.cause).toEqual( - new Error('Custom error for GET https://example.com/'), - ) + expect(response.status).toBe(500) + expect(response.statusText).toBe('Unhandled Exception') + expect(await response.json()).toEqual({ + name: 'Error', + message: 'Custom error for GET https://example.com/', + stack: expect.any(String), + }) }) diff --git a/test/node/rest-api/response/throw-response.node.test.ts b/test/node/rest-api/response/throw-response.node.test.ts index a4625647b..b2ac3197f 100644 --- a/test/node/rest-api/response/throw-response.node.test.ts +++ b/test/node/rest-api/response/throw-response.node.test.ts @@ -95,19 +95,20 @@ it('supports middleware-style responses', async () => { expect(await errorResponse.text()).toBe('must have id') }) -it('throws non-Response errors as-is', async () => { +it('handles non-response errors as 500 error responses', async () => { server.use( http.get('https://example.com/', () => { - throw new Error('Oops!') + throw new Error('Custom error') }), ) - const requestError = await fetch('https://example.com/') - .then(() => null) - .catch((error) => error) + const response = await fetch('https://example.com') - expect(requestError.name).toBe('TypeError') - expect(requestError.message).toBe('Failed to fetch') - // Undici forwards the original error in the "cause" property. - expect(requestError.cause).toEqual(new Error('Oops!')) + expect(response.status).toBe(500) + expect(response.statusText).toBe('Unhandled Exception') + expect(await response.json()).toEqual({ + name: 'Error', + message: 'Custom error', + stack: expect.any(String), + }) }) From 82e62a203e2a13e171a9e64ecc837b30d1b5b377 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 17 Apr 2024 14:44:19 +0200 Subject: [PATCH 3/9] chore: update @mswjs/interceptors to 0.28.2 --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a54123e1e..648584cf8 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.28.1", + "@mswjs/interceptors": "^0.28.2", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6de1f9f57..7055ddf2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^1.1.0 version: 1.1.0 '@mswjs/interceptors': - specifier: ^0.28.1 - version: 0.28.1 + specifier: ^0.28.2 + version: 0.28.2 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -1410,8 +1410,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors@0.28.1: - resolution: {integrity: sha512-wveXxNJ/GwpWzckD/2vU1o24+Ib97kEfc/luiP1YvF0dSziE+VhsGh2uqysR7pLZGWv22gKNs0nJGboEY+yBtw==} + /@mswjs/interceptors@0.28.2: + resolution: {integrity: sha512-ckCfUjWG4Sd4f1rWjIocAFTl8eyQKmW/ypnnvM0NclT2Qr7UKqzJNjhXUanF/glhVTqLnb/9jfqvBMy8jjcx7Q==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 2b8e67bb4a4f60c177f2fea8d0312b8aab2baea6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 21 Apr 2024 11:12:55 +0200 Subject: [PATCH 4/9] test: rename "response-patching" test suite --- ...onse-patching..node.test.ts => response-patching.node.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/node/msw-api/setup-server/scenarios/{response-patching..node.test.ts => response-patching.node.test.ts} (100%) 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 similarity index 100% rename from test/node/msw-api/setup-server/scenarios/response-patching..node.test.ts rename to test/node/msw-api/setup-server/scenarios/response-patching.node.test.ts From 3ece00e01e8bb6dc677ef60a490cb4d07d43d760 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 21 Apr 2024 11:13:10 +0200 Subject: [PATCH 5/9] test(error.node): rewrite jsdoc --- .../scenarios/on-unhandled-request/error.node.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 36f022abd..a84080bb6 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 @@ -31,8 +31,9 @@ beforeAll(async () => { 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. + // The handler that has no return value so it falls through any + // other matching handlers (whicbh are none). In the end, + // the request is performed as-is and is still considered handled. }), ) server.listen({ onUnhandledRequest: 'error' }) From 82d45c72df4ef5dc3d66a7efd0c34e4598220a80 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 21 Apr 2024 11:13:44 +0200 Subject: [PATCH 6/9] chore: upgrade to @mswjs/interceptors@0.28.3 --- package.json | 2 +- pnpm-lock.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 648584cf8..101c3ce54 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.28.2", + "@mswjs/interceptors": "^0.28.3", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7055ddf2f..944767acc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^1.1.0 version: 1.1.0 '@mswjs/interceptors': - specifier: ^0.28.2 - version: 0.28.2 + specifier: ^0.28.3 + version: 0.28.3 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -1410,8 +1410,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors@0.28.2: - resolution: {integrity: sha512-ckCfUjWG4Sd4f1rWjIocAFTl8eyQKmW/ypnnvM0NclT2Qr7UKqzJNjhXUanF/glhVTqLnb/9jfqvBMy8jjcx7Q==} + /@mswjs/interceptors@0.28.3: + resolution: {integrity: sha512-+4px/YLSyG3nL+R6nOKJpK59OvmuSl4CHH8wq8/7oGiZgDJTDOXMAgBrUPrjB5wScv81d/Drk1tP8+/BePBJfQ==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 From 1faf9a1e013757afcdf88f875c78e324427665b1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 26 Apr 2024 13:19:07 +0200 Subject: [PATCH 7/9] fix(setupServer): throw InternalError in listeners as-is --- package.json | 2 +- pnpm-lock.yaml | 8 +-- src/core/utils/internal/devUtils.ts | 4 ++ src/core/utils/request/onUnhandledRequest.ts | 6 +-- src/node/SetupServerCommonApi.ts | 8 ++- .../on-unhandled-request/error.node.test.ts | 7 +-- .../response/response-cookies.test.ts | 50 +++++++++++++++++++ 7 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 test/node/rest-api/response/response-cookies.test.ts diff --git a/package.json b/package.json index 101c3ce54..2a2cd1556 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", "@mswjs/cookies": "^1.1.0", - "@mswjs/interceptors": "^0.28.3", + "@mswjs/interceptors": "^0.29.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 944767acc..2aa6f5fa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ dependencies: specifier: ^1.1.0 version: 1.1.0 '@mswjs/interceptors': - specifier: ^0.28.3 - version: 0.28.3 + specifier: ^0.29.0 + version: 0.29.0 '@open-draft/until': specifier: ^2.1.0 version: 2.1.0 @@ -1410,8 +1410,8 @@ packages: engines: {node: '>=18'} dev: false - /@mswjs/interceptors@0.28.3: - resolution: {integrity: sha512-+4px/YLSyG3nL+R6nOKJpK59OvmuSl4CHH8wq8/7oGiZgDJTDOXMAgBrUPrjB5wScv81d/Drk1tP8+/BePBJfQ==} + /@mswjs/interceptors@0.29.0: + resolution: {integrity: sha512-eppU9TxaRS2t5IcR00nuh+36zMHcK09pyhUvWJLO1ae5+U8KL7iatUGKlLUlbxXaq3BvDjlcF0Q8Xhzyosk/xA==} engines: {node: '>=18'} dependencies: '@open-draft/deferred-promise': 2.2.0 diff --git a/src/core/utils/internal/devUtils.ts b/src/core/utils/internal/devUtils.ts index 47abfb754..e39b69264 100644 --- a/src/core/utils/internal/devUtils.ts +++ b/src/core/utils/internal/devUtils.ts @@ -29,3 +29,7 @@ export const devUtils = { warn, error, } + +export class InternalError extends Error { + // +} diff --git a/src/core/utils/request/onUnhandledRequest.ts b/src/core/utils/request/onUnhandledRequest.ts index fcf4d5055..02708b139 100644 --- a/src/core/utils/request/onUnhandledRequest.ts +++ b/src/core/utils/request/onUnhandledRequest.ts @@ -1,5 +1,5 @@ import { toPublicUrl } from './toPublicUrl' -import { devUtils } from '../internal/devUtils' +import { InternalError, devUtils } from '../internal/devUtils' export interface UnhandledRequestPrint { warning(): void @@ -33,7 +33,7 @@ export async function onUnhandledRequest( devUtils.error('Error: %s', unhandledRequestMessage) // Throw an exception to halt request processing and not perform the original request. - throw new Error( + throw new InternalError( devUtils.formatMessage( 'Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.', ), @@ -49,7 +49,7 @@ export async function onUnhandledRequest( break default: - throw new Error( + throw new InternalError( devUtils.formatMessage( 'Failed to react to an unhandled request: unknown strategy "%s". Please provide one of the supported strategies ("bypass", "warn", "error") or a custom callback function as the value of the "onUnhandledRequest" option.', strategy, diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index fc742429f..85c534d31 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -15,7 +15,7 @@ import { SetupApi } from '~/core/SetupApi' import { handleRequest } from '~/core/utils/handleRequest' import type { RequestHandler } from '~/core/handlers/RequestHandler' import { mergeRight } from '~/core/utils/internal/mergeRight' -import { devUtils } from '~/core/utils/internal/devUtils' +import { InternalError, devUtils } from '~/core/utils/internal/devUtils' import type { SetupServerCommon } from './glossary' export const DEFAULT_LISTEN_OPTIONS: RequiredDeep = { @@ -68,6 +68,12 @@ export class SetupServerCommonApi return }) + this.interceptor.on('unhandledException', ({ error }) => { + if (error instanceof InternalError) { + throw error + } + }) + this.interceptor.on( 'response', ({ response, isMockedResponse, request, requestId }) => { 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 a84080bb6..307a22c36 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 @@ -68,12 +68,7 @@ test('errors on unhandled request when using the "error" value', async () => { const requestError = await makeRequest() - expect(requestError.message).toBe('Failed to fetch') - /** - * @note Undici wraps fetch rejections in a generic "Failed to fetch" error, - * forwarding the actual rejection in the "error.cause" property. - */ - expect(requestError.cause).toEqual( + expect(requestError).toEqual( new Error( '[MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option.', ), diff --git a/test/node/rest-api/response/response-cookies.test.ts b/test/node/rest-api/response/response-cookies.test.ts new file mode 100644 index 000000000..bfdcaefd7 --- /dev/null +++ b/test/node/rest-api/response/response-cookies.test.ts @@ -0,0 +1,50 @@ +/** + * @vitest-environment node + */ +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' + +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +it('supports mocking a response cookie', async () => { + server.use( + http.get('*/resource', () => { + return new HttpResponse(null, { + headers: { + 'Set-Cookie': 'a=1', + }, + }) + }), + ) + + const response = await fetch('http://localhost/resource') + expect(response.headers.get('Set-Cookie')).toBe('a=1') +}) + +it('supports mocking multiple response cookies', async () => { + server.use( + http.get('*/resource', () => { + return new HttpResponse(null, { + headers: [ + ['Set-Cookie', 'a=1'], + ['Set-Cookie', 'b=2'], + ], + }) + }), + ) + + const response = await fetch('http://localhost/resource') + expect(response.headers.get('Set-Cookie')).toBe('a=1, b=2') +}) From 826fb091ecd21a5f5180e3ca61e453b8bb60b2f3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 8 May 2024 14:11:46 +0200 Subject: [PATCH 8/9] test: rewrite "on.node.test.ts" suite --- .../life-cycle-events/on.node.test.ts | 267 +++++++++++------- 1 file changed, 166 insertions(+), 101 deletions(-) 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 8219ade2e..be58a7427 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,9 +2,8 @@ * @vitest-environment node */ import { HttpResponse, http } from 'msw' -import { setupServer } from 'msw/node' +import { SetupServerApi, setupServer } from 'msw/node' import { HttpServer } from '@open-draft/test-server/http' -import { waitFor } from '../../../../support/waitFor' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => res.status(500).end()) @@ -14,18 +13,34 @@ const httpServer = new HttpServer((app) => { const server = setupServer() -const listener = vi.fn() +function spyOnEvents(server: SetupServerApi) { + const listener = vi.fn() + const wrapListener = (eventName: string, listener: any) => { + return (...args) => listener(eventName, ...args) + } -function getRequestId(requestStartListener: vi.Mock) { - const { calls } = requestStartListener.mock - const requestStartCall = calls.find((call) => { - return call[0].startsWith('[request:start]') - }) + server.events.on('request:start', wrapListener('request:start', listener)) + server.events.on('request:match', wrapListener('request:match', listener)) + server.events.on( + 'request:unhandled', + wrapListener('request:unhandled', listener), + ) + server.events.on('request:end', wrapListener('request:end', listener)) + server.events.on('response:mocked', wrapListener('response:mocked', listener)) + server.events.on('response:bypass', wrapListener('response:bypass', listener)) + server.events.on( + 'unhandledException', + wrapListener('unhandledException', listener), + ) - return requestStartCall[0].split(' ')[3] + return listener } beforeAll(async () => { + // Supress "Expected a mocking resolver function to return a mocked response" + // warnings when hitting intentionally empty resolver. + vi.spyOn(global.console, 'warn').mockImplementation(() => void 0) + await httpServer.listen() server.use( @@ -40,62 +55,6 @@ beforeAll(async () => { }), ) server.listen() - - server.events.on('request:start', ({ request, requestId }) => { - listener(`[request:start] ${request.method} ${request.url} ${requestId}`) - }) - - server.events.on('request:match', ({ request, requestId }) => { - listener(`[request:match] ${request.method} ${request.url} ${requestId}`) - }) - - server.events.on('request:unhandled', ({ request, requestId }) => { - listener( - `[request:unhandled] ${request.method} ${request.url} ${requestId}`, - ) - }) - - server.events.on('request:end', ({ request, requestId }) => { - listener(`[request:end] ${request.method} ${request.url} ${requestId}`) - }) - - server.events.on( - 'response:mocked', - async ({ response, request, requestId }) => { - listener( - `[response:mocked] ${await response.text()} ${request.method} ${ - request.url - } ${requestId}`, - ) - }, - ) - - server.events.on( - 'response:bypass', - async ({ response, request, requestId }) => { - listener( - `[response:bypass] ${await response.text()} ${request.method} ${ - request.url - } ${requestId}`, - ) - }, - ) - - server.events.on('unhandledException', ({ error, request, requestId }) => { - listener( - `[unhandledException] ${request.method} ${request.url} ${requestId} ${error.message}`, - ) - }) -}) - -beforeEach(() => { - // Supress "Expected a mocking resolver function to return a mocked response" - // warnings. Using intentional explicit empty resolver. - vi.spyOn(global.console, 'warn').mockImplementation(() => void 0) -}) - -afterEach(() => { - vi.restoreAllMocks() }) afterAll(async () => { @@ -103,103 +62,209 @@ afterAll(async () => { await httpServer.close() }) -test('emits events for a handler request and mocked response', async () => { +test('emits events for a handled request and mocked response', async () => { + const listener = spyOnEvents(server) const url = httpServer.http.url('/user') 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}`, + 'request:start', + expect.objectContaining({ + request: expect.any(Request), + requestId: expect.any(String), + }), ) + + const { request, requestId } = listener.mock.calls[0][1] + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(listener).toHaveBeenNthCalledWith( 2, - `[request:match] GET ${url} ${requestId}`, + 'request:match', + expect.objectContaining({ + request, + requestId, + }), ) expect(listener).toHaveBeenNthCalledWith( 3, - `[request:end] GET ${url} ${requestId}`, + 'request:end', + expect.objectContaining({ + request, + requestId, + }), ) expect(listener).toHaveBeenNthCalledWith( 4, - `[response:mocked] response-body GET ${url} ${requestId}`, + 'response:mocked', + expect.objectContaining({ + request, + requestId, + response: expect.any(Response), + }), ) + + const { response } = listener.mock.calls[3][1] + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(await response.text()).toBe('response-body') + expect(listener).toHaveBeenCalledTimes(4) }) test('emits events for a handled request with no response', async () => { + const listener = spyOnEvents(server) const url = httpServer.http.url('/no-response') await fetch(url, { method: 'POST' }) - const requestId = getRequestId(listener) - - await waitFor(() => { - expect(listener).toHaveBeenCalledWith( - expect.stringContaining('[response:bypass]'), - ) - }) expect(listener).toHaveBeenNthCalledWith( 1, - `[request:start] POST ${url} ${requestId}`, + 'request:start', + expect.objectContaining({ + request: expect.any(Request), + requestId: expect.any(String), + }), ) + + const { request, requestId } = listener.mock.calls[0][1] + expect(request.method).toBe('POST') + expect(request.url).toBe(url) + expect(listener).toHaveBeenNthCalledWith( 2, - `[request:end] POST ${url} ${requestId}`, + 'request:end', + expect.objectContaining({ + request, + requestId, + }), ) expect(listener).toHaveBeenNthCalledWith( 3, - `[response:bypass] original-response POST ${url} ${requestId}`, + 'response:bypass', + expect.objectContaining({ + request, + requestId, + response: expect.any(Response), + }), ) + + const { response } = listener.mock.calls[2][1] + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(await response.text()).toBe('original-response') + expect(listener).toHaveBeenCalledTimes(3) }) test('emits events for an unhandled request', async () => { + const listener = spyOnEvents(server) const url = httpServer.http.url('/unknown-route') await fetch(url) - const requestId = getRequestId(listener) - - await waitFor(() => { - expect(listener).toHaveBeenCalledWith( - expect.stringContaining('[response:bypass]'), - ) - }) expect(listener).toHaveBeenNthCalledWith( 1, - `[request:start] GET ${url} ${requestId}`, + 'request:start', + expect.objectContaining({ + request: expect.any(Request), + requestId: expect.any(String), + }), ) + + const { request, requestId } = listener.mock.calls[0][1] + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + expect(listener).toHaveBeenNthCalledWith( 2, - `[request:unhandled] GET ${url} ${requestId}`, + 'request:unhandled', + expect.objectContaining({ + request, + requestId, + }), ) expect(listener).toHaveBeenNthCalledWith( 3, - `[request:end] GET ${url} ${requestId}`, + 'request:end', + expect.objectContaining({ + request, + requestId, + }), ) + expect(listener).toHaveBeenNthCalledWith( 4, - `[response:bypass] majestic-unknown GET ${url} ${requestId}`, + 'response:bypass', + expect.objectContaining({ + request, + requestId, + response: expect.any(Response), + }), ) + + const { response } = listener.mock.calls[3][1] + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(await response.text()).toBe('majestic-unknown') + + expect(listener).toHaveBeenCalledTimes(4) }) test('emits unhandled exceptions in the request handler', async () => { + const listener = spyOnEvents(server) const url = httpServer.http.url('/unhandled-exception') await fetch(url).catch(() => undefined) - const requestId = getRequestId(listener) - expect(listener).toHaveBeenCalledWith( - `[unhandledException] GET ${url} ${requestId} Unhandled resolver error`, + expect(listener).toHaveBeenNthCalledWith( + 1, + 'request:start', + expect.objectContaining({ + request: expect.any(Request), + requestId: expect.any(String), + }), + ) + + const { request, requestId } = listener.mock.calls[0][1] + expect(request.method).toBe('GET') + expect(request.url).toBe(url) + + expect(listener).toHaveBeenNthCalledWith( + 2, + 'unhandledException', + expect.objectContaining({ + request, + requestId, + error: new Error('Unhandled resolver error'), + }), + ) + + /** + * @note The fallback 500 response still counts as a mocked response. + * I'm torn on this but I believe we should indicate that the request + * (a) received a response; (b) that response was mocked. + */ + expect(listener).toHaveBeenNthCalledWith( + 3, + 'response:mocked', + expect.objectContaining({ + request, + requestId, + response: expect.any(Response), + }), ) + + const { response } = listener.mock.calls[2][1] + expect(response.status).toBe(500) + expect(response.statusText).toBe('Unhandled Exception') + + expect(listener).toHaveBeenCalledTimes(3) }) test('stops emitting events once the server is stopped', async () => { + const listener = spyOnEvents(server) server.close() + await fetch(httpServer.http.url('/user')) expect(listener).not.toHaveBeenCalled() From 6ca4cadf1a0a077f7ab600eca65d297cbe47f61d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 8 May 2024 14:25:12 +0200 Subject: [PATCH 9/9] test: add unit tests for "InternalError" --- src/core/utils/internal/devUtils.test.ts | 21 +++++++++++++++++++++ src/core/utils/internal/devUtils.ts | 11 ++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/core/utils/internal/devUtils.test.ts diff --git a/src/core/utils/internal/devUtils.test.ts b/src/core/utils/internal/devUtils.test.ts new file mode 100644 index 000000000..76dd20192 --- /dev/null +++ b/src/core/utils/internal/devUtils.test.ts @@ -0,0 +1,21 @@ +import { InternalError } from './devUtils' + +describe(InternalError, () => { + it('creates an InternalError instance', () => { + const error = new InternalError('Message') + + expect(error.name).toBe('InternalError') + expect(error.message).toBe('Message') + expect(error.toString()).toBe('InternalError: Message') + expect(error.stack).toMatch(/\w+/) + }) + + it('passes the identity check', () => { + const error = new InternalError('Message') + expect(error instanceof InternalError).toBe(true) + expect(error instanceof Error).toBe(true) + + const extraneousError = new Error('Message') + expect(extraneousError).not.toBeInstanceOf(InternalError) + }) +}) diff --git a/src/core/utils/internal/devUtils.ts b/src/core/utils/internal/devUtils.ts index e39b69264..08e5005e3 100644 --- a/src/core/utils/internal/devUtils.ts +++ b/src/core/utils/internal/devUtils.ts @@ -30,6 +30,15 @@ export const devUtils = { error, } +/** + * Internal error instance. + * Used to differentiate the library errors that must be forwarded + * to the user from the unhandled exceptions. Use this if you don't + * wish for the error to be coerced to a 500 fallback response. + */ export class InternalError extends Error { - // + constructor(message: string) { + super(message) + this.name = 'InternalError' + } }