diff --git a/src/BatchInterceptor.test.ts b/src/BatchInterceptor.test.ts index 935304ed..251a9378 100644 --- a/src/BatchInterceptor.test.ts +++ b/src/BatchInterceptor.test.ts @@ -80,13 +80,13 @@ it('proxies event listeners to the interceptors', () => { }) it('disposes of child interceptors', async () => { - class PrimaryInterceptor extends Interceptor { + class PrimaryInterceptor extends Interceptor { constructor() { super(Symbol('primary')) } } - class SecondaryInterceptor extends Interceptor { + class SecondaryInterceptor extends Interceptor { constructor() { super(Symbol('secondary')) } diff --git a/src/RemoteHttpInterceptor.ts b/src/RemoteHttpInterceptor.ts index fa4b6a29..1be906f1 100644 --- a/src/RemoteHttpInterceptor.ts +++ b/src/RemoteHttpInterceptor.ts @@ -131,7 +131,9 @@ export class RemoteHttpResolver extends Interceptor { } this.emitter.emit('request', interactiveIsomorphicRequest) - await this.emitter.untilIdle('request') + await this.emitter.untilIdle('request', ({ args: [request] }) => { + return request.id === interactiveIsomorphicRequest.id + }) const [mockedResponse] = await interactiveIsomorphicRequest.respondWith.invoked() diff --git a/src/interceptors/ClientRequest/NodeClientRequest.ts b/src/interceptors/ClientRequest/NodeClientRequest.ts index 0c79dffb..6ac16c4b 100644 --- a/src/interceptors/ClientRequest/NodeClientRequest.ts +++ b/src/interceptors/ClientRequest/NodeClientRequest.ts @@ -159,8 +159,15 @@ export class NodeClientRequest extends ClientRequest { // Execute the resolver Promise like a side-effect. // Node.js 16 forces "ClientRequest.end" to be synchronous and return "this". until(async () => { - await this.emitter.untilIdle('request') - this.log('all request listeners have been resolved!') + await this.emitter.untilIdle('request', ({ args: [request] }) => { + /** + * @note Await only those listeners that are relevant to this request. + * This prevents extraneous parallel request from blocking the resolution + * of another, unrelated request. For example, during response patching, + * when request resolution is nested. + */ + return request.id === interactiveIsomorphicRequest.id + }) const [mockedResponse] = await interactiveIsomorphicRequest.respondWith.invoked() diff --git a/src/interceptors/XMLHttpRequest/XMLHttpRequestOverride.ts b/src/interceptors/XMLHttpRequest/XMLHttpRequestOverride.ts index 3343ac89..78a747ba 100644 --- a/src/interceptors/XMLHttpRequest/XMLHttpRequestOverride.ts +++ b/src/interceptors/XMLHttpRequest/XMLHttpRequestOverride.ts @@ -272,7 +272,9 @@ export const createXMLHttpRequestOverride = ( Promise.resolve( until(async () => { - await emitter.untilIdle('request') + await emitter.untilIdle('request', ({ args: [request] }) => { + return request.id === interactiveIsomorphicRequest.id + }) this.log('all request listeners have been resolved!') const [mockedResponse] = diff --git a/src/interceptors/fetch/index.ts b/src/interceptors/fetch/index.ts index bf4943ac..977b99ec 100644 --- a/src/interceptors/fetch/index.ts +++ b/src/interceptors/fetch/index.ts @@ -33,6 +33,7 @@ export class FetchInterceptor extends Interceptor { globalThis.fetch = async (input, init) => { const request = new Request(input, init) + const url = typeof input === 'string' ? input : input.url const method = request.method @@ -58,7 +59,9 @@ export class FetchInterceptor extends Interceptor { this.log('awaiting for the mocked response...') - await this.emitter.untilIdle('request') + await this.emitter.untilIdle('request', ({ args: [request] }) => { + return request.id === isomorphicRequest.id + }) this.log('all request listeners have been resolved!') const [mockedResponse] = await isomorphicRequest.respondWith.invoked() diff --git a/src/utils/AsyncEventEmitter.test.ts b/src/utils/AsyncEventEmitter.test.ts index 55f68c4c..38345392 100644 --- a/src/utils/AsyncEventEmitter.test.ts +++ b/src/utils/AsyncEventEmitter.test.ts @@ -1,6 +1,10 @@ import { AsyncEventEmitter } from './AsyncEventEmitter' import { sleep } from '../../test/helpers' +afterEach(() => { + jest.useRealTimers() +}) + it('emits and listens to events', () => { const emitter = new AsyncEventEmitter<{ hello(name: string): void }>() const listener = jest.fn() @@ -35,6 +39,35 @@ it('resolves "untilIdle" when all the event listeners are done', async () => { expect(results).toEqual(['first', 'second']) }) +it('resolves "untilIdle" only for the relevant listeners', async () => { + const emitter = new AsyncEventEmitter<{ signal(code: number): void }>() + + const results: number[] = [] + const listener = jest.fn(async (code: number) => { + if (code !== 1) { + // Delay listener based on the signal code. + await sleep(150) + } + + results.push(code) + }) + emitter.on('signal', listener) + + emitter.emit('signal', 1) + emitter.emit('signal', 2) + + const resultsAfterIdle = await emitter + .untilIdle('signal', ({ args: [code] }) => { + return code === 1 + }) + .then(() => results) + + await emitter.untilIdle('signal') + + expect(listener).toHaveBeenCalled() + expect(resultsAfterIdle).toEqual([1]) +}) + it('resolves "untilIdle" immediately if there are no pending listeners', async () => { const emitter = new AsyncEventEmitter<{ ping(): void }>() emitter.emit('ping') diff --git a/src/utils/AsyncEventEmitter.ts b/src/utils/AsyncEventEmitter.ts index 7568da62..d580a50a 100644 --- a/src/utils/AsyncEventEmitter.ts +++ b/src/utils/AsyncEventEmitter.ts @@ -2,7 +2,10 @@ import { Debugger, debug } from 'debug' import { StrictEventEmitter, EventMapType } from 'strict-event-emitter' import { nextTick } from './nextTick' -export type QueueItem = Promise +export interface QueueItem { + args: Args + done: Promise +} export enum AsyncEventEmitterReadyState { ACTIVE = 'ACTIVE', @@ -15,7 +18,10 @@ export class AsyncEventEmitter< public readyState: AsyncEventEmitterReadyState private log: Debugger - protected queue: Map + protected queue: Map< + keyof EventMap, + QueueItem>[] + > constructor() { super() @@ -39,7 +45,7 @@ export class AsyncEventEmitter< return this } - return super.on(event, (async (...args: unknown[]) => { + return super.on(event, (async (...args: Parameters) => { // Event queue is always established when calling ".emit()". const queue = this.openListenerQueue(event) @@ -47,8 +53,9 @@ export class AsyncEventEmitter< // Whenever a listener is called, create a new Promise // that resolves when that listener function completes its execution. - queue.push( - new Promise(async (resolve, reject) => { + queue.push({ + args, + done: new Promise(async (resolve, reject) => { try { // Treat listeners as potentially asynchronous functions // so they could be awaited. @@ -60,8 +67,8 @@ export class AsyncEventEmitter< log('"%s" listener has rejected!', error) reject(error) } - }) - ) + }), + }) }) as EventMap[Event]) } @@ -103,10 +110,15 @@ export class AsyncEventEmitter< * If the event has no listeners, resolves immediately. */ public async untilIdle( - event: Event + event: Event, + filter: (item: QueueItem>) => boolean = () => + true ): Promise { const listenersQueue = this.queue.get(event) || [] - await Promise.all(listenersQueue).finally(() => { + + await Promise.all( + listenersQueue.filter(filter).map(({ done }) => done) + ).finally(() => { // Clear the queue one the promise settles // so that different events don't share the same queue. this.queue.delete(event) @@ -115,7 +127,7 @@ export class AsyncEventEmitter< private openListenerQueue( event: Event - ): QueueItem[] { + ): QueueItem>[] { const log = this.log.extend('openListenerQueue') log('opening "%s" listeners queue...', event) diff --git a/test/helpers.ts b/test/helpers.ts index 587a72b9..6a4b6988 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -205,10 +205,19 @@ export async function extractRequestFromPage( page: Page ): Promise { const request = await page.evaluate(() => { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + const timeoutTimer = setTimeout(() => { + reject( + new Error( + 'Browser runtime module did not dispatch the custom "resolver" event' + ) + ) + }, 5000) + window.addEventListener( 'resolver' as any, (event: CustomEvent) => { + clearTimeout(timeoutTimer) resolve(JSON.parse(event.detail)) } ) diff --git a/test/jest.browser.config.js b/test/jest.browser.config.js index 4d706fdc..5deab493 100644 --- a/test/jest.browser.config.js +++ b/test/jest.browser.config.js @@ -1,5 +1,5 @@ module.exports = { - testTimeout: 60000, + testTimeout: 15000, testMatch: ['**/*.browser.test.ts'], setupFilesAfterEnv: ['./jest.browser.setup.ts'], transform: { diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js new file mode 100644 index 00000000..fc01d2c7 --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.runtime.js @@ -0,0 +1,43 @@ +import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/lib/interceptors/XMLHttpRequest' + +const interceptor = new XMLHttpRequestInterceptor() + +interceptor.on('request', async (request) => { + window.dispatchEvent( + new CustomEvent('resolver', { + detail: JSON.stringify({ + id: request.id, + method: request.method, + url: request.url.href, + headers: request.headers.all(), + credentials: request.credentials, + body: request.body, + }), + }) + ) + + if (request.url.pathname === '/mocked') { + await new Promise((resolve) => setTimeout(resolve, 0)) + + const req = new XMLHttpRequest() + req.open('GET', window.originalUrl, true) + req.send() + await new Promise((resolve, reject) => { + req.addEventListener('loadend', resolve) + req.addEventListener('error', reject) + }) + + request.respondWith({ + status: req.status, + statusText: req.statusText, + headers: { + 'X-Custom-Header': req.getResponseHeader('X-Custom-Header'), + }, + body: `${req.responseText} world`, + }) + } +}) + +interceptor.apply() + +window.interceptor = interceptor diff --git a/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts new file mode 100644 index 00000000..2df1b9ff --- /dev/null +++ b/test/modules/XMLHttpRequest/response/xhr-response-patching.browser.test.ts @@ -0,0 +1,58 @@ +/** + * @jest-environment node + */ +import * as path from 'path' +import { pageWith } from 'page-with' +import { HttpServer } from '@open-draft/test-server/http' +import { createBrowserXMLHttpRequest } from '../../../helpers' + +declare namespace window { + export let originalUrl: string +} + +const httpServer = new HttpServer((app) => { + app.get('/original', (req, res) => { + res + .set('access-control-expose-headers', 'x-custom-header') + .set('x-custom-header', 'yes') + .send('hello') + }) +}) + +async function prepareRuntime() { + const runtime = await pageWith({ + example: path.resolve( + __dirname, + 'xhr-response-patching.browser.runtime.js' + ), + }) + + await runtime.page.evaluate((url) => { + window.originalUrl = url + }, httpServer.http.url('/original')) + + return runtime +} + +beforeAll(async () => { + await httpServer.listen() +}) + +afterAll(async () => { + await httpServer.close() +}) + +test('responds to an HTTP request handled in the resolver', async () => { + const runtime = await prepareRuntime() + const callXMLHttpRequest = createBrowserXMLHttpRequest(runtime) + + const [, response] = await callXMLHttpRequest({ + method: 'GET', + url: 'http://localhost/mocked', + }) + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(response.headers).toBe('x-custom-header: yes') + expect(response.body).toBe('hello world') +}) diff --git a/test/modules/fetch/response/fetch-response-patching.browser.test.ts b/test/modules/fetch/response/fetch-response-patching.browser.test.ts new file mode 100644 index 00000000..7b30cea3 --- /dev/null +++ b/test/modules/fetch/response/fetch-response-patching.browser.test.ts @@ -0,0 +1,65 @@ +/** + * @jest-environment node + */ +import * as path from 'path' +import { pageWith } from 'page-with' +import { HttpServer } from '@open-draft/test-server/http' +import { FetchInterceptor } from '../../../../src/interceptors/fetch' +import { listToHeaders } from 'headers-polyfill/lib' + +declare namespace window { + export const interceptor: FetchInterceptor + export let originalUrl: string +} + +const httpServer = new HttpServer((app) => { + app.get('/original', (req, res) => { + res + .set('access-control-expose-headers', 'x-custom-header') + .set('x-custom-header', 'yes') + .send('hello') + }) +}) + +async function prepareRuntime() { + const context = await pageWith({ + example: path.resolve(__dirname, 'fetch-response-patching.runtime.js'), + }) + + await context.page.evaluate((url) => { + window.originalUrl = url + }, httpServer.http.url('/original')) + + return context +} + +beforeAll(async () => { + await httpServer.listen() +}) + +afterAll(async () => { + await httpServer.close() +}) + +it('supports response patching', async () => { + const runtime = await prepareRuntime() + + const res = await runtime.page.evaluate(() => { + return fetch('http://localhost/mocked').then((res) => { + return res.text().then((text) => { + return { + status: res.status, + statusText: res.statusText, + headers: Array.from(res.headers.entries()), + text, + } + }) + }) + }) + const headers = listToHeaders(res.headers) + + expect(res.status).toBe(200) + expect(res.statusText).toBe('OK') + expect(headers.get('x-custom-header')).toBe('yes') + expect(res.text).toBe('hello world') +}) diff --git a/test/modules/fetch/response/fetch-response-patching.runtime.js b/test/modules/fetch/response/fetch-response-patching.runtime.js new file mode 100644 index 00000000..3d4821a4 --- /dev/null +++ b/test/modules/fetch/response/fetch-response-patching.runtime.js @@ -0,0 +1,26 @@ +import { FetchInterceptor } from '@mswjs/interceptors/lib/interceptors/fetch' + +const interceptor = new FetchInterceptor() + +interceptor.on('request', async (request) => { + if (request.url.pathname === '/mocked') { + await new Promise((resolve) => setTimeout(resolve, 0)) + + const originalResponse = await fetch(window.originalUrl) + const originalText = await originalResponse.text() + + request.respondWith({ + status: originalResponse.status, + statusText: originalResponse.statusText, + headers: { + 'X-Custom-Header': + originalResponse.headers.get('X-Custom-Header') || '', + }, + body: `${originalText} world`, + }) + } +}) + +interceptor.apply() + +window.interceptor = interceptor diff --git a/test/modules/fetch/response/fetch.browser.runtime.js b/test/modules/fetch/response/fetch.browser.runtime.js index 8c801196..0193da8a 100644 --- a/test/modules/fetch/response/fetch.browser.runtime.js +++ b/test/modules/fetch/response/fetch.browser.runtime.js @@ -1,6 +1,7 @@ import { FetchInterceptor } from '@mswjs/interceptors/lib/interceptors/fetch' const interceptor = new FetchInterceptor() + interceptor.on('request', (request) => { const { serverHttpUrl, serverHttpsUrl } = window diff --git a/test/modules/fetch/response/fetch.browser.test.ts b/test/modules/fetch/response/fetch.browser.test.ts index b521b809..e112c62b 100644 --- a/test/modules/fetch/response/fetch.browser.test.ts +++ b/test/modules/fetch/response/fetch.browser.test.ts @@ -55,119 +55,115 @@ afterAll(async () => { await httpServer.close() }) -describe('HTTP', () => { - test('responds to an HTTP request handled in the resolver', async () => { - const context = await prepareRuntime() - const response: SerializedResponse = await context.page.evaluate((url) => { - return fetch(url).then((response) => { - return response.json().then((json) => ({ - type: response.type, - status: response.status, - statusText: response.statusText, - headers: Array.from( - // @ts-ignore - response.headers.entries() - ), - json, - })) - }) - }, httpServer.http.url('/')) - const headers = listToHeaders(response.headers) - - expect(response.type).toBe('default') - expect(response.status).toBe(201) - expect(response.statusText).toBe('OK') - expect(headers.get('content-type')).toBe('application/hal+json') - expect(headers).not.toHaveProperty('map') - expect(headers.has('map')).toBe(false) - expect(response.json).toEqual({ - mocked: true, +test('responds to an HTTP request handled in the resolver', async () => { + const context = await prepareRuntime() + const response: SerializedResponse = await context.page.evaluate((url) => { + return fetch(url).then((response) => { + return response.json().then((json) => ({ + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Array.from( + // @ts-ignore + response.headers.entries() + ), + json, + })) }) + }, httpServer.http.url('/')) + const headers = listToHeaders(response.headers) + + expect(response.type).toBe('default') + expect(response.status).toBe(201) + expect(response.statusText).toBe('OK') + expect(headers.get('content-type')).toBe('application/hal+json') + expect(headers).not.toHaveProperty('map') + expect(headers.has('map')).toBe(false) + expect(response.json).toEqual({ + mocked: true, }) +}) - test('bypasses an HTTP request not handled in the resolver', async () => { - const context = await prepareRuntime() - const response: SerializedResponse = await context.page.evaluate((url) => { - return fetch(url).then((response) => { - return { - type: response.type, - status: response.status, - statusText: response.statusText, - headers: Array.from( - // @ts-ignore - response.headers.entries() - ), - } - }) - }, httpServer.http.url('/get')) - const headers = listToHeaders(response.headers) - - expect(response.type).toBe('cors') - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(headers.get('content-type')).toBe('application/json; charset=utf-8') - expect(headers).not.toHaveProperty('map') - expect(headers.has('map')).toBe(false) - }) +test('bypasses an HTTP request not handled in the resolver', async () => { + const context = await prepareRuntime() + const response: SerializedResponse = await context.page.evaluate((url) => { + return fetch(url).then((response) => { + return { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Array.from( + // @ts-ignore + response.headers.entries() + ), + } + }) + }, httpServer.http.url('/get')) + const headers = listToHeaders(response.headers) + + expect(response.type).toBe('cors') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(headers.get('content-type')).toBe('application/json; charset=utf-8') + expect(headers).not.toHaveProperty('map') + expect(headers.has('map')).toBe(false) }) -describe('HTTPS', () => { - test('responds to an HTTPS request handled in the resolver', async () => { - const context = await prepareRuntime() - const response: SerializedResponse = await context.page.evaluate((url) => { - /** - * @todo give a custom Agent to allow HTTPS on insecure hosts. - */ - return fetch(url).then((response) => { - return response.json().then((json) => ({ - type: response.type, - status: response.status, - statusText: response.statusText, - headers: Array.from( - // @ts-ignore - response.headers.entries() - ), - json, - })) - }) - }, httpServer.https.url('/')) - const headers = listToHeaders(response.headers) - - expect(response.type).toBe('default') - expect(response.status).toBe(201) - expect(response.statusText).toBe('OK') - expect(headers.get('content-type')).toBe('application/hal+json') - expect(headers).not.toHaveProperty('map') - expect(headers.has('map')).toBe(false) - expect(response.json).toEqual({ - mocked: true, +test('responds to an HTTPS request handled in the resolver', async () => { + const context = await prepareRuntime() + const response: SerializedResponse = await context.page.evaluate((url) => { + /** + * @todo give a custom Agent to allow HTTPS on insecure hosts. + */ + return fetch(url).then((response) => { + return response.json().then((json) => ({ + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Array.from( + // @ts-ignore + response.headers.entries() + ), + json, + })) }) + }, httpServer.https.url('/')) + const headers = listToHeaders(response.headers) + + expect(response.type).toBe('default') + expect(response.status).toBe(201) + expect(response.statusText).toBe('OK') + expect(headers.get('content-type')).toBe('application/hal+json') + expect(headers).not.toHaveProperty('map') + expect(headers.has('map')).toBe(false) + expect(response.json).toEqual({ + mocked: true, }) +}) - test('bypasses an HTTPS request not handled in the resolver', async () => { - const context = await prepareRuntime() - const response: SerializedResponse = await context.page.evaluate((url) => { - return fetch(url).then((response) => { - return { - type: response.type, - status: response.status, - statusText: response.statusText, - headers: Array.from( - // @ts-ignore - response.headers.entries() - ), - } - }) - }, httpServer.https.url('/get')) - const headers = listToHeaders(response.headers) - - expect(response.type).toBe('cors') - expect(response.status).toBe(200) - expect(response.statusText).toBe('OK') - expect(headers.get('content-type')).toBe('application/json; charset=utf-8') - expect(headers).not.toHaveProperty('map') - expect(headers.has('map')).toBe(false) - }) +test('bypasses an HTTPS request not handled in the resolver', async () => { + const context = await prepareRuntime() + const response: SerializedResponse = await context.page.evaluate((url) => { + return fetch(url).then((response) => { + return { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Array.from( + // @ts-ignore + response.headers.entries() + ), + } + }) + }, httpServer.https.url('/get')) + const headers = listToHeaders(response.headers) + + expect(response.type).toBe('cors') + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(headers.get('content-type')).toBe('application/json; charset=utf-8') + expect(headers).not.toHaveProperty('map') + expect(headers.has('map')).toBe(false) }) test('bypasses any request when the interceptor is restored', async () => { diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts new file mode 100644 index 00000000..e45f51d1 --- /dev/null +++ b/test/modules/http/response/http-response-patching.test.ts @@ -0,0 +1,81 @@ +/** + * @jest-environment node + */ +import * as http from 'http' +import { HttpServer } from '@open-draft/test-server/http' +import { + BatchInterceptor, + InteractiveIsomorphicRequest, + MockedResponse, +} from '../../../../src' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { XMLHttpRequestInterceptor } from '../../../../src/interceptors/XMLHttpRequest' +import { sleep, waitForClientRequest } from '../../../helpers' + +const server = new HttpServer((app) => { + app.get('/original', async (req, res) => { + res.header('X-Custom-Header', 'yes').send('hello') + }) +}) + +const interceptor = new BatchInterceptor({ + name: 'response-patching', + interceptors: [ + new ClientRequestInterceptor(), + new XMLHttpRequestInterceptor(), + ], +}) + +async function getResponse( + request: InteractiveIsomorphicRequest +): Promise { + switch (request.url.pathname) { + case '/mocked': { + return new Promise(async (resolve) => { + // Defer the resolution of the promise to the next tick. + // Request handlers in MSW resolve on the next tick. + await sleep(0) + + const originalRequest = http.get(server.http.url('/original')) + const { res, text } = await waitForClientRequest(originalRequest) + + resolve({ + status: res.statusCode, + statusText: res.statusMessage, + headers: { + 'X-Custom-Header': res.headers['x-custom-header'] || '', + }, + body: (await text()) + ' world', + }) + }) + } + } +} + +interceptor.on('request', async (request) => { + const response = await getResponse(request) + + if (response) { + request.respondWith(response) + } +}) + +beforeAll(async () => { + interceptor.apply() + await server.listen() +}) + +afterAll(async () => { + interceptor.dispose() + await server.close() +}) + +test('supports response patching', async () => { + const req = http.get('http://localhost/mocked') + const { res, text } = await waitForClientRequest(req) + + expect(res.statusCode).toBe(200) + expect(res.statusMessage).toBe('OK') + expect(res.headers['x-custom-header']).toBe('yes') + expect(await text()).toBe('hello world') +})