From 58a089ad1dbcf7e0c71d87bd3a13db246d812a6b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 17 Apr 2024 14:38:51 +0200 Subject: [PATCH] fix: treat thrown responses as mocked responses (#553) --- .../ClientRequest/NodeClientRequest.ts | 9 +++- src/interceptors/ClientRequest/index.test.ts | 2 +- .../XMLHttpRequest/XMLHttpRequestProxy.ts | 5 ++ src/interceptors/fetch/index.ts | 51 +++++++++++-------- .../xhr-middleware-exception.test.ts | 37 +++++++++++--- test/modules/fetch/fetch-exception.test.ts | 24 +++++++-- .../http-unhandled-exception.test.ts | 51 +++++++++++++++++++ 7 files changed, 145 insertions(+), 34 deletions(-) create mode 100644 test/modules/http/compliance/http-unhandled-exception.test.ts diff --git a/src/interceptors/ClientRequest/NodeClientRequest.ts b/src/interceptors/ClientRequest/NodeClientRequest.ts index ac0b6257..34e352d0 100644 --- a/src/interceptors/ClientRequest/NodeClientRequest.ts +++ b/src/interceptors/ClientRequest/NodeClientRequest.ts @@ -1,4 +1,4 @@ -import { ClientRequest, IncomingMessage } from 'node:http' +import { ClientRequest, IncomingMessage, STATUS_CODES } from 'node:http' import type { Logger } from '@open-draft/logger' import { until } from '@open-draft/until' import { DeferredPromise } from '@open-draft/deferred-promise' @@ -266,6 +266,11 @@ export class NodeClientRequest extends ClientRequest { resolverResult.error ) + if (resolverResult.error instanceof Response) { + this.respondWith(resolverResult.error) + return + } + // Allow throwing Node.js-like errors, like connection rejection errors. // Treat them as request errors. if (isNodeLikeError(resolverResult.error)) { @@ -522,7 +527,7 @@ export class NodeClientRequest extends ClientRequest { const { status, statusText, headers, body } = mockedResponse this.response.statusCode = status - this.response.statusMessage = statusText + this.response.statusMessage = statusText || STATUS_CODES[status] // Try extracting the raw headers from the headers instance. // If not possible, fallback to the headers instance as-is. diff --git a/src/interceptors/ClientRequest/index.test.ts b/src/interceptors/ClientRequest/index.test.ts index 4cde38b5..935b73e0 100644 --- a/src/interceptors/ClientRequest/index.test.ts +++ b/src/interceptors/ClientRequest/index.test.ts @@ -54,7 +54,7 @@ it('forbids calling "respondWith" multiple times for the same request', async () const response = await responseReceived expect(response.statusCode).toBe(200) - expect(response.statusMessage).toBe('') + expect(response.statusMessage).toBe('OK') }) it('abort the request if the abort signal is emitted', async () => { diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts index 3b7c6518..15b5ba2c 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestProxy.ts @@ -94,6 +94,11 @@ export function createXMLHttpRequestProxy({ resolverResult.error ) + if (resolverResult.error instanceof Response) { + this.respondWith(resolverResult.error) + return + } + // Treat unhandled exceptions in the "request" listener // as 500 server errors. xhrRequestController.respondWith( diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index 4097dc57..fea9f3e6 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -85,6 +85,30 @@ export class FetchInterceptor extends Interceptor { ) } + const respondWith = (response: Response): Response => { + // Clone the mocked response for the "response" event listener. + // This way, the listener can read the response and not lock its body + // for the actual fetch consumer. + const responseClone = response.clone() + + this.emitter.emit('response', { + response: responseClone, + isMockedResponse: true, + request: interactiveRequest, + requestId, + }) + + // Set the "response.url" property to equal the intercepted request URL. + Object.defineProperty(response, 'url', { + writable: false, + enumerable: true, + configurable: false, + value: request.url, + }) + + return response + } + const resolverResult = await until(async () => { const listenersFinished = emitAsync(this.emitter, 'request', { request: interactiveRequest, @@ -113,6 +137,11 @@ export class FetchInterceptor extends Interceptor { } if (resolverResult.error) { + // Treat thrown Responses as mocked responses. + if (resolverResult.error instanceof Response) { + return respondWith(resolverResult.error) + } + // Treat unhandled exceptions from the "request" listeners // as 500 errors from the server. Fetch API doesn't respect // Node.js internal errors so no special treatment for those. @@ -157,27 +186,7 @@ export class FetchInterceptor extends Interceptor { return Promise.reject(createNetworkError(mockedResponse)) } - // Clone the mocked response for the "response" event listener. - // This way, the listener can read the response and not lock its body - // for the actual fetch consumer. - const responseClone = mockedResponse.clone() - - this.emitter.emit('response', { - response: responseClone, - isMockedResponse: true, - request: interactiveRequest, - requestId, - }) - - // Set the "response.url" property to equal the intercepted request URL. - Object.defineProperty(mockedResponse, 'url', { - writable: false, - enumerable: true, - configurable: false, - value: request.url, - }) - - return mockedResponse + return respondWith(mockedResponse) } this.logger.info('no mocked response received!') diff --git a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts index b2833f14..89e3b5b1 100644 --- a/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts +++ b/test/modules/XMLHttpRequest/compliance/xhr-middleware-exception.test.ts @@ -2,25 +2,30 @@ /** * @see https://github.com/mswjs/msw/issues/355 */ -import { it, expect, beforeAll, afterAll } from 'vitest' +import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import axios from 'axios' import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' import { createXMLHttpRequest } from '../../../helpers' const interceptor = new XMLHttpRequestInterceptor() -interceptor.on('request', () => { - throw new Error('Custom error message') -}) beforeAll(() => { interceptor.apply() }) +afterEach(() => { + interceptor.removeAllListeners() +}) + afterAll(() => { interceptor.dispose() }) it('XMLHttpRequest: treats unhandled interceptor exceptions as 500 responses', async () => { + interceptor.on('request', () => { + throw new Error('Custom error') + }) + const request = await createXMLHttpRequest((request) => { request.responseType = 'json' request.open('GET', 'http://localhost/api') @@ -31,12 +36,16 @@ it('XMLHttpRequest: treats unhandled interceptor exceptions as 500 responses', a expect(request.statusText).toBe('Unhandled Exception') expect(request.response).toEqual({ name: 'Error', - message: 'Custom error message', + message: 'Custom error', stack: expect.any(String), }) }) it('axios: unhandled interceptor exceptions are treated as 500 responses', async () => { + interceptor.on('request', () => { + throw new Error('Custom error') + }) + const error = await axios.get('https://test.mswjs.io').catch((error) => error) /** @@ -48,7 +57,23 @@ it('axios: unhandled interceptor exceptions are treated as 500 responses', async expect(error.response.statusText).toBe('Unhandled Exception') expect(error.response.data).toEqual({ name: 'Error', - message: 'Custom error message', + message: 'Custom error', stack: expect.any(String), }) }) + +it('treats a thrown Response instance as a mocked response', async () => { + interceptor.on('request', () => { + throw new Response('hello world') + }) + + const request = await createXMLHttpRequest((request) => { + request.responseType = 'text' + request.open('GET', 'http://localhost/api') + request.send() + }) + + expect(request.status).toBe(200) + expect(request.response).toBe('hello world') + expect(request.responseText).toBe('hello world') +}) diff --git a/test/modules/fetch/fetch-exception.test.ts b/test/modules/fetch/fetch-exception.test.ts index 21ed8c13..6338bf92 100644 --- a/test/modules/fetch/fetch-exception.test.ts +++ b/test/modules/fetch/fetch-exception.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll } from 'vitest' +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { FetchInterceptor } from '../../../src/interceptors/fetch' const interceptor = new FetchInterceptor() @@ -8,9 +8,10 @@ beforeAll(() => { vi.spyOn(console, 'error').mockImplementation(() => void 0) interceptor.apply() - interceptor.on('request', () => { - throw new Error('Network error') - }) +}) + +afterEach(() => { + interceptor.removeAllListeners() }) afterAll(() => { @@ -19,6 +20,10 @@ afterAll(() => { }) it('treats middleware exceptions as 500 responses', async () => { + interceptor.on('request', () => { + throw new Error('Network error') + }) + const response = await fetch('http://localhost:3001/resource') expect(response.status).toBe(500) @@ -29,3 +34,14 @@ it('treats middleware exceptions as 500 responses', async () => { stack: expect.any(String), }) }) + +it('treats a thrown Response as a mocked response', async () => { + interceptor.on('request', () => { + throw new Response('hello world') + }) + + const response = await fetch('http://localhost:3001/resource') + + expect(response.status).toBe(200) + expect(await response.text()).toBe('hello world') +}) diff --git a/test/modules/http/compliance/http-unhandled-exception.test.ts b/test/modules/http/compliance/http-unhandled-exception.test.ts new file mode 100644 index 00000000..b3f0e3fd --- /dev/null +++ b/test/modules/http/compliance/http-unhandled-exception.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import http from 'node:http' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { waitForClientRequest } from '../../../helpers' + +const interceptor = new ClientRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('handles a thrown Response as a mocked response', async () => { + interceptor.on('request', () => { + throw new Response('hello world') + }) + + const request = http.get('http://localhost/resource') + const { res, text } = await waitForClientRequest(request) + + expect(res.statusCode).toBe(200) + expect(res.statusMessage).toBe('OK') + expect(await text()).toBe('hello world') +}) + +it('treats unhandled interceptor errors as 500 responses', async () => { + interceptor.on('request', () => { + throw new Error('Custom error') + }) + + const request = http.get('http://localhost/resource') + const { res, text } = await waitForClientRequest(request) + + expect(res.statusCode).toBe(500) + expect(res.statusMessage).toBe('Unhandled Exception') + expect(JSON.parse(await text())).toEqual({ + name: 'Error', + message: 'Custom error', + stack: expect.any(String), + }) +})