diff --git a/package.json b/package.json index 349ab1d2f..2dc5f7568 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "@bundled-es-modules/statuses": "^1.0.1", "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.37.0", + "@mswjs/interceptors": "^0.37.5", "@open-draft/deferred-promise": "^2.2.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10fb7b3a5..d384927a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^5.0.0 version: 5.0.2(@types/node@18.19.28) '@mswjs/interceptors': - specifier: ^0.37.0 - version: 0.37.1 + specifier: ^0.37.5 + version: 0.37.5 '@open-draft/deferred-promise': specifier: ^2.2.0 version: 2.2.0 @@ -999,8 +999,8 @@ packages: resolution: {integrity: sha512-stTxvLdJ2IcGOs76AnvGYAzGvx8JvQPRxC5DW0P5zdAAnhL33noqb5LKdPt3P37BKp9FzBKZHuihQI9oVqwm0g==} engines: {node: '>=16.13'} - '@mswjs/interceptors@0.37.1': - resolution: {integrity: sha512-SvE+tSpcX884RJrPCskXxoS965Ky/pYABDEhWW6oeSRhpUDLrS5nTvT5n1LLSDVDYvty4imVmXsy+3/ROVuknA==} + '@mswjs/interceptors@0.37.5': + resolution: {integrity: sha512-AAwRb5vXFcY4L+FvZ7LZusDuZ0vEe0Zm8ohn1FM6/X7A3bj4mqmkAcGRWuvC2JwSygNwHAAmMnAI73vPHeqsHA==} engines: {node: '>=18'} '@nodelib/fs.scandir@2.1.5': @@ -2127,10 +2127,6 @@ packages: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} - cookie@0.4.2: - resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} - engines: {node: '>= 0.6'} - cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} @@ -2420,8 +2416,8 @@ packages: resolution: {integrity: sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==} engines: {node: '>=10.0.0'} - engine.io@6.5.4: - resolution: {integrity: sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==} + engine.io@6.6.2: + resolution: {integrity: sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==} engines: {node: '>=10.2.0'} enhanced-resolve@5.17.1: @@ -4354,8 +4350,8 @@ packages: resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} engines: {node: '>=10.0.0'} - socket.io@4.7.5: - resolution: {integrity: sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==} + socket.io@4.8.1: + resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} engines: {node: '>=10.2.0'} sonic-boom@2.8.0: @@ -5070,6 +5066,18 @@ packages: utf-8-validate: optional: true + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -5902,7 +5910,7 @@ snapshots: - bufferutil - utf-8-validate - '@mswjs/interceptors@0.37.1': + '@mswjs/interceptors@0.37.5': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -5938,7 +5946,7 @@ snapshots: cors: 2.8.5 express: 4.19.2 outvariant: 1.4.3 - socket.io: 4.7.5 + socket.io: 4.8.1 transitivePeerDependencies: - bufferutil - supports-color @@ -7167,8 +7175,6 @@ snapshots: cookie-signature@1.2.2: {} - cookie@0.4.2: {} - cookie@0.6.0: {} cookie@0.7.1: {} @@ -7416,18 +7422,18 @@ snapshots: engine.io-parser@5.2.2: {} - engine.io@6.5.4: + engine.io@6.6.2: dependencies: '@types/cookie': 0.4.1 '@types/cors': 2.8.17 '@types/node': 18.19.28 accepts: 1.3.8 base64id: 2.0.0 - cookie: 0.4.2 + cookie: 0.7.2 cors: 2.8.5 - debug: 4.3.4 + debug: 4.3.7 engine.io-parser: 5.2.2 - ws: 8.11.0 + ws: 8.17.1 transitivePeerDependencies: - bufferutil - supports-color @@ -9647,7 +9653,7 @@ snapshots: socket.io-adapter@2.5.4: dependencies: - debug: 4.3.4 + debug: 4.3.7 ws: 8.11.0 transitivePeerDependencies: - bufferutil @@ -9657,17 +9663,17 @@ snapshots: socket.io-parser@4.2.4: dependencies: '@socket.io/component-emitter': 3.1.0 - debug: 4.3.4 + debug: 4.3.7 transitivePeerDependencies: - supports-color - socket.io@4.7.5: + socket.io@4.8.1: dependencies: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.5 - debug: 4.3.4 - engine.io: 6.5.4 + debug: 4.3.7 + engine.io: 6.6.2 socket.io-adapter: 2.5.4 socket.io-parser: 4.2.4 transitivePeerDependencies: @@ -10411,6 +10417,8 @@ snapshots: ws@8.11.0: {} + ws@8.17.1: {} + ws@8.18.0: {} xml-name-validator@5.0.0: {} diff --git a/src/browser/tsconfig.browser.json b/src/browser/tsconfig.browser.json index e998a8e8e..8c9a9092c 100644 --- a/src/browser/tsconfig.browser.json +++ b/src/browser/tsconfig.browser.json @@ -1,9 +1,15 @@ { - "extends": "../tsconfig.src.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { + "composite": true, // Expose browser-specific libraries only for the // source code under the "src/browser" directory. - "lib": ["DOM", "WebWorker"] + "lib": ["DOM", "dom.iterable", "WebWorker"], + "baseUrl": "./", + "paths": { + "~/core": ["../core"], + "~/core/*": ["../core/*"] + } }, - "include": ["../../global.d.ts", "./global.browser.d.ts", "./**/*.ts"] + "include": ["./global.browser.d.ts", "./**/*.ts"] } diff --git a/src/core/HttpResponse.ts b/src/core/HttpResponse.ts index 7df08d54f..159aed856 100644 --- a/src/core/HttpResponse.ts +++ b/src/core/HttpResponse.ts @@ -167,7 +167,7 @@ export class HttpResponse extends Response { responseInit.headers.set('Content-Length', body.byteLength.toString()) } - return new HttpResponse(body, responseInit) + return new HttpResponse(body as ArrayBuffer, responseInit) } /** diff --git a/src/core/RemoteClient.ts b/src/core/RemoteClient.ts new file mode 100644 index 000000000..542a4e62d --- /dev/null +++ b/src/core/RemoteClient.ts @@ -0,0 +1,309 @@ +import * as http from 'node:http' +import { Readable } from 'node:stream' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { FetchResponse } from '@mswjs/interceptors' +import { invariant } from 'outvariant' +import type { LifeCycleEventsMap } from './sharedOptions' +import { bypass } from './bypass' +import { delay } from './delay' + +export type ForwardedLifeCycleEventPayload = { + type: keyof LifeCycleEventsMap + args: { + requestId: string + request: { + method: string + url: string + headers: Array<[string, string]> + body: Uint8Array | null + } + response?: { + status: number + statusText: string + headers: Array<[string, string]> + body: Uint8Array | null + } + error?: { + name: string + message: string + stack?: string + } + } +} + +export class RemoteClient { + public connected: boolean + + protected agent: http.Agent + + constructor(private readonly url: URL) { + this.agent = new http.Agent({ + // Reuse the same socket between requests so we can communicate + // request's life-cycle events via HTTP more efficiently. + keepAlive: true, + }) + this.connected = false + } + + public async connect(): Promise { + if (this.connected) { + return + } + + const maxRetries = 4 + let retries = 0 + + const tryConnect = (): Promise => { + const connectionPromise = new DeferredPromise() + + const request = http + .request(this.url, { + agent: this.agent, + method: 'HEAD', + headers: { + accept: 'msw/passthrough', + }, + timeout: 1000, + }) + .end() + + request + .once('response', (response) => { + if (response.statusCode === 200) { + connectionPromise.resolve() + } else { + connectionPromise.reject() + } + }) + .once('error', () => { + connectionPromise.reject() + }) + .once('timeout', () => { + connectionPromise.reject() + }) + + return connectionPromise.then( + () => { + this.connected = true + }, + async () => { + invariant( + retries < maxRetries, + 'Failed to connect to the remote server after %s retries', + maxRetries, + ) + + retries++ + request.removeAllListeners() + return delay(500).then(() => tryConnect()) + }, + ) + } + + return tryConnect() + } + + public async handleRequest(args: { + requestId: string + boundaryId: string + request: Request + }): Promise { + invariant( + this.connected, + 'Failed to handle request "%s %s": client is not connected', + args.request.method, + args.request.url, + ) + + const fetchRequest = bypass(args.request, { + headers: { + accept: 'msw/internal', + 'x-msw-request-url': args.request.url, + 'x-msw-request-id': args.requestId, + 'x-msw-boundary-id': args.boundaryId, + }, + }) + const request = http.request(this.url, { + method: fetchRequest.method, + headers: Object.fromEntries(fetchRequest.headers), + }) + + if (fetchRequest.body) { + Readable.fromWeb(fetchRequest.body as any).pipe(request, { end: true }) + } else { + request.end() + } + + const responsePromise = new DeferredPromise() + + request + .once('response', (response) => { + if (response.statusCode === 404) { + responsePromise.resolve(undefined) + return + } + + const fetchResponse = new FetchResponse( + /** @fixme Node.js types incompatibility */ + Readable.toWeb(response) as ReadableStream, + { + url: fetchRequest.url, + status: response.statusCode, + statusText: response.statusMessage, + headers: FetchResponse.parseRawHeaders(response.rawHeaders), + }, + ) + responsePromise.resolve(fetchResponse) + }) + .once('error', () => { + responsePromise.resolve(undefined) + }) + .once('timeout', () => { + responsePromise.resolve(undefined) + }) + + return responsePromise + } + + public async handleLifeCycleEvent< + EventType extends keyof LifeCycleEventsMap, + >(event: { + type: EventType + args: LifeCycleEventsMap[EventType][0] + }): Promise { + invariant( + this.connected, + 'Failed to forward life-cycle events for "%s %s": remote client not connected', + event.args.request.method, + event.args.request.url, + ) + + const url = new URL('/life-cycle-events', this.url) + const payload = JSON.stringify({ + type: event.type, + args: { + requestId: event.args.requestId, + request: await serializeFetchRequest(event.args.request), + response: + 'response' in event.args + ? await serializeFetchResponse(event.args.response) + : undefined, + error: + 'error' in event.args ? serializeError(event.args.error) : undefined, + }, + } satisfies ForwardedLifeCycleEventPayload) + + invariant( + payload, + 'Failed to serialize a life-cycle event "%s" for request "%s %s"', + event.type, + event.args.request.method, + event.args.request.url, + ) + + const donePromise = new DeferredPromise() + + http + .request( + url, + { + method: 'POST', + headers: { + accept: 'msw/passthrough, msw/internal', + 'content-type': 'application/json', + }, + }, + (response) => { + if (response.statusCode === 200) { + donePromise.resolve() + } else { + donePromise.reject( + new Error( + `Failed to forward life-cycle event "${event.type}" for request "${event.args.request.method} ${event.args.request.url}": expected a 200 response but got ${response.statusCode}`, + ), + ) + } + }, + ) + .end(payload) + .once('error', (error) => { + // eslint-disable-next-line no-console + console.error(error) + donePromise.reject( + new Error( + `Failed to forward life-cycle event "${event.type}" for request "${event.args.request.method} ${event.args.request.url}": unexpected error. There's likely additional information above.`, + ), + ) + }) + + return donePromise + } +} + +export async function serializeFetchRequest( + request: Request, +): Promise { + return { + url: request.url, + method: request.method, + headers: Array.from(request.headers), + body: request.body + ? new Uint8Array(await request.clone().arrayBuffer()) + : null, + } +} + +export function deserializeFetchRequest( + value: NonNullable, +): Request { + return new Request(value.url, { + method: value.method, + headers: value.headers, + body: value.body, + }) +} + +async function serializeFetchResponse( + response: Response, +): Promise { + return { + status: response.status, + statusText: response.statusText, + headers: Array.from(response.headers), + body: response.body + ? new Uint8Array(await response.clone().arrayBuffer()) + : null, + } +} + +export function deserializeFetchResponse( + value: NonNullable, +): Response { + return new FetchResponse( + value.body ? new Uint8Array(Object.values(value.body)) : null, + { + status: value.status, + statusText: value.statusText, + headers: value.headers, + }, + ) +} + +export function serializeError( + error: Error, +): ForwardedLifeCycleEventPayload['args']['error'] { + return { + name: error.name, + message: error.message, + stack: error.stack, + } +} + +export function deserializeError( + value: NonNullable, +): Error { + const error = new Error(value.message) + error.name = value.name + error.stack = value.stack + return error +} diff --git a/src/core/SetupApi.ts b/src/core/SetupApi.ts index e908e5994..9c761233a 100644 --- a/src/core/SetupApi.ts +++ b/src/core/SetupApi.ts @@ -2,6 +2,7 @@ import { invariant } from 'outvariant' import { EventMap, Emitter } from 'strict-event-emitter' import { RequestHandler } from './handlers/RequestHandler' import { LifeCycleEventEmitter } from './sharedOptions' +import { isObject } from './utils/internal/isObject' import { devUtils } from './utils/internal/devUtils' import { pipeEvents } from './utils/internal/pipeEvents' import { toReadonlyArray } from './utils/internal/toReadonlyArray' @@ -65,7 +66,22 @@ export abstract class SetupApi extends Disposable { this.emitter = new Emitter() this.publicEmitter = new Emitter() - pipeEvents(this.emitter, this.publicEmitter) + pipeEvents(this.emitter, this.publicEmitter, (_, ...data) => { + /** + * @note Prevent forwarding of internal HTTP requests to the public emitter. + * Those requests, such as the one for remote interception handshake, must never + * surface to the developer. + * + * @fixme This isn't nice. It leaks specific event types into this generic API. + * Find a better way for this, and for life-cycle events in general. + */ + return !( + isObject(data[0]) && + 'request' in data[0] && + data[0].request instanceof Request && + data[0].request?.headers.get('accept')?.includes('msw/internal') + ) + }) this.events = this.createLifeCycleEvents() diff --git a/src/core/handlers/RemoteRequestHandler.ts b/src/core/handlers/RemoteRequestHandler.ts new file mode 100644 index 000000000..7daddb1b2 --- /dev/null +++ b/src/core/handlers/RemoteRequestHandler.ts @@ -0,0 +1,113 @@ +import { createRequestId } from '@mswjs/interceptors' +import type { ResponseResolutionContext } from '../utils/executeHandlers' +import { + RequestHandler, + type ResponseResolver, + type RequestHandlerDefaultInfo, +} from './RequestHandler' +import type { RemoteClient } from '../RemoteClient' + +interface RemoteRequestHandlerParsedResult { + response: Response | undefined +} + +type RemoteRequestHandlerResolverExtras = { + response: Response | undefined +} + +export class RemoteRequestHandler extends RequestHandler< + RequestHandlerDefaultInfo, + RemoteRequestHandlerParsedResult, + RemoteRequestHandlerResolverExtras +> { + protected remoteClient: RemoteClient + protected boundaryId: string + + constructor( + readonly args: { + remoteClient: RemoteClient + boundaryId: string + }, + ) { + super({ + info: { + header: 'RemoteRequestHandler', + }, + resolver() {}, + }) + + this.remoteClient = args.remoteClient + this.boundaryId = args.boundaryId + } + + async parse(args: { + request: Request + resolutionContext?: ResponseResolutionContext + }): Promise { + const parsedResult = await super.parse(args) + + if (!this.remoteClient.connected) { + return parsedResult + } + + /** + * @note Remote request handler is special. + * It cannot await the mocked response from the remote process in + * the resolver because that would mark it is matching, preventing + * MSW from treating unhandled requests as unhandled. + * + * Instead, the remote handler await the mocked response during the + * parsing phase since that's the only async phase before predicate. + */ + const response = await this.remoteClient.handleRequest({ + boundaryId: this.boundaryId, + requestId: createRequestId(), + request: args.request, + }) + + parsedResult.response = response + return parsedResult + } + + predicate(args: { + request: Request + parsedResult: RemoteRequestHandlerParsedResult + }): boolean { + // The remote handler is considered matching if the remote process + // returned any mocked response for the intercepted request. + return typeof args.parsedResult.response !== 'undefined' + } + + protected extendResolverArgs(args: { + request: Request + parsedResult: RemoteRequestHandlerParsedResult + }): RemoteRequestHandlerResolverExtras { + const resolverInfo = super.extendResolverArgs(args) + + return { + ...resolverInfo, + // Propagate the mocked response returned from the remote server + // onto the resolver function. + response: args.parsedResult.response, + } + } + + protected resolver: ResponseResolver< + RemoteRequestHandlerResolverExtras, + any, + any + > = ({ response }) => { + // Return the mocked response received from the remote process as-is. + return response + } + + log(_args: { + request: Request + response: Response + parsedResult: RemoteRequestHandlerParsedResult + }): void { + // Intentionally skip logging the remote request handler. + // This is an internal handler so let's not confuse the developer. + return + } +} diff --git a/src/core/passthrough.test.ts b/src/core/passthrough.test.ts index 5a7a5ee21..51df077cd 100644 --- a/src/core/passthrough.test.ts +++ b/src/core/passthrough.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { passthrough } from './passthrough' -it('creates a 302 response with the intention header', () => { +it('creates a 302 response with the correct request intention header', () => { const response = passthrough() expect(response).toBeInstanceOf(Response) diff --git a/src/core/passthrough.ts b/src/core/passthrough.ts index 2dbe84f67..a0701998f 100644 --- a/src/core/passthrough.ts +++ b/src/core/passthrough.ts @@ -1,4 +1,8 @@ import type { StrictResponse } from './HttpResponse' +import { + REQUEST_INTENTION_HEADER_NAME, + RequestIntention, +} from './utils/internal/requestUtils' /** * Performs the intercepted request as-is. @@ -19,7 +23,7 @@ export function passthrough(): StrictResponse { status: 302, statusText: 'Passthrough', headers: { - 'x-msw-intention': 'passthrough', + [REQUEST_INTENTION_HEADER_NAME]: RequestIntention.passthrough, }, }) as StrictResponse } diff --git a/src/core/utils/handleRequest.ts b/src/core/utils/handleRequest.ts index b7cd2599e..e35ddb1f5 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -6,6 +6,10 @@ import type { RequestHandler } from '../handlers/RequestHandler' import { HandlersExecutionResult, executeHandlers } from './executeHandlers' import { onUnhandledRequest } from './request/onUnhandledRequest' import { storeResponseCookies } from './request/storeResponseCookies' +import { + shouldBypassRequest, + isPassthroughResponse, +} from './internal/requestUtils' export interface HandleRequestOptions { /** @@ -46,8 +50,8 @@ export async function handleRequest( ): Promise { emitter.emit('request:start', { request, requestId }) - // Perform requests wrapped in "bypass()" as-is. - if (request.headers.get('accept')?.includes('msw/passthrough')) { + // Perform bypassed requests (i.e. wrapped in "bypass()") as-is. + if (shouldBypassRequest(request)) { emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return @@ -93,12 +97,9 @@ export async function handleRequest( return } - // Perform the request as-is when the developer explicitly returned "req.passthrough()". + // Perform the request as-is when the developer explicitly returned `passthrough()`. // This produces no warning as the request was handled. - if ( - response.status === 302 && - response.headers.get('x-msw-intention') === 'passthrough' - ) { + if (isPassthroughResponse(response)) { emitter.emit('request:end', { request, requestId }) handleRequestOptions?.onPassthroughResponse?.(request) return diff --git a/src/core/utils/internal/isObject.ts b/src/core/utils/internal/isObject.ts index e4b730fc8..6dac43338 100644 --- a/src/core/utils/internal/isObject.ts +++ b/src/core/utils/internal/isObject.ts @@ -1,6 +1,6 @@ /** * Determines if the given value is an object. */ -export function isObject(value: any): boolean { +export function isObject(value: any): value is object { return value != null && typeof value === 'object' && !Array.isArray(value) } diff --git a/src/core/utils/internal/pipeEvents.ts b/src/core/utils/internal/pipeEvents.ts index 43b57cd4e..0efdbe1e5 100644 --- a/src/core/utils/internal/pipeEvents.ts +++ b/src/core/utils/internal/pipeEvents.ts @@ -6,6 +6,10 @@ import { Emitter, EventMap } from 'strict-event-emitter' export function pipeEvents( source: Emitter, destination: Emitter, + filterEvent: ( + event: E, + ...data: Events[E] + ) => boolean = () => true, ): void { const rawEmit: typeof source.emit & { _isPiped?: boolean } = source.emit @@ -15,7 +19,10 @@ export function pipeEvents( const sourceEmit: typeof source.emit & { _isPiped?: boolean } = function sourceEmit(this: typeof source, event, ...data) { - destination.emit(event, ...data) + if (filterEvent(event, ...data)) { + destination.emit(event, ...data) + } + return rawEmit.call(this, event, ...data) } diff --git a/src/core/utils/internal/requestUtils.ts b/src/core/utils/internal/requestUtils.ts new file mode 100644 index 000000000..9930129f6 --- /dev/null +++ b/src/core/utils/internal/requestUtils.ts @@ -0,0 +1,17 @@ +export const REQUEST_INTENTION_HEADER_NAME = 'x-msw-intention' + +export enum RequestIntention { + passthrough = 'passthrough', +} + +export function shouldBypassRequest(request: Request): boolean { + return !!request.headers.get('accept')?.includes('msw/passthrough') +} + +export function isPassthroughResponse(response: Response): boolean { + return ( + response.status === 302 && + response.headers.get(REQUEST_INTENTION_HEADER_NAME) === + RequestIntention.passthrough + ) +} diff --git a/src/node/SetupServerApi.ts b/src/node/SetupServerApi.ts index 4c86847c8..8cd4374bd 100644 --- a/src/node/SetupServerApi.ts +++ b/src/node/SetupServerApi.ts @@ -1,14 +1,21 @@ import { AsyncLocalStorage } from 'node:async_hooks' +import { invariant } from 'outvariant' import { ClientRequestInterceptor } from '@mswjs/interceptors/ClientRequest' import { XMLHttpRequestInterceptor } from '@mswjs/interceptors/XMLHttpRequest' import { FetchInterceptor } from '@mswjs/interceptors/fetch' import { HandlersController } from '~/core/SetupApi' import type { RequestHandler } from '~/core/handlers/RequestHandler' +import type { ListenOptions, SetupServer } from './glossary' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' -import type { SetupServer } from './glossary' import { SetupServerCommonApi } from './SetupServerCommonApi' +import { RemoteRequestHandler } from '~/core/handlers/RemoteRequestHandler' +import { shouldBypassRequest } from '~/core/utils/internal/requestUtils' +import { getRemoteContextFromEnvironment } from './remoteContext' +import { LifeCycleEventsMap } from '~/core/sharedOptions' +import { devUtils } from '~/core/utils/internal/devUtils' +import { RemoteClient } from '~/core/RemoteClient' -const store = new AsyncLocalStorage() +const handlersStorage = new AsyncLocalStorage() type RequestHandlersContext = { initialHandlers: Array @@ -20,15 +27,29 @@ type RequestHandlersContext = { * to prevent the request handlers list from being a shared state * across mutliple tests. */ -class AsyncHandlersController implements HandlersController { +export class AsyncHandlersController implements HandlersController { + private storage: AsyncLocalStorage private rootContext: RequestHandlersContext - constructor(initialHandlers: Array) { - this.rootContext = { initialHandlers, handlers: [] } + constructor(args: { + storage: AsyncLocalStorage + initialHandlers: Array + }) { + this.storage = args.storage + this.rootContext = { + initialHandlers: args.initialHandlers, + handlers: [], + } } get context(): RequestHandlersContext { - return store.getStore() || this.rootContext + const store = this.storage.getStore() + + if (store) { + return store + } + + return this.rootContext } public prepend(runtimeHandlers: Array) { @@ -52,20 +73,25 @@ export class SetupServerApi extends SetupServerCommonApi implements SetupServer { + protected remoteClient?: RemoteClient + constructor(handlers: Array) { super( [ClientRequestInterceptor, XMLHttpRequestInterceptor, FetchInterceptor], handlers, ) - this.handlersController = new AsyncHandlersController(handlers) + this.handlersController = new AsyncHandlersController({ + storage: handlersStorage, + initialHandlers: handlers, + }) } public boundary, R>( callback: (...args: Args) => R, ): (...args: Args) => R { return (...args: Args): R => { - return store.run( + return handlersStorage.run( { initialHandlers: this.handlersController.currentHandlers(), handlers: [], @@ -78,6 +104,96 @@ export class SetupServerApi public close(): void { super.close() - store.disable() + handlersStorage.disable() + } + + public listen(options?: Partial): void { + super.listen(options) + + // If the "remotePort" option has been provided to the server, + // run it in a special "remote" mode. That mode ensures that + // an extraneous Node.js process can affect this process' traffic. + if (this.resolvedOptions.remote?.enabled) { + // Get the remote context from the environment since `server.listen()` + // is called in a different process and cannot be wrapped in `remote.boundary()`. + const remoteContext = getRemoteContextFromEnvironment() + const remoteClient = new RemoteClient(remoteContext.serverUrl) + this.remoteClient = remoteClient + + // Connect to the remote server early. + const remoteConnectionPromise = remoteClient.connect().then( + () => { + // Forward the life-cycle events from this process to the remote. + this.forwardLifeCycleEventsToRemote() + + this.handlersController.currentHandlers = new Proxy( + this.handlersController.currentHandlers, + { + apply: (target, thisArg, args) => { + return Array.prototype.concat( + new RemoteRequestHandler({ + remoteClient, + // Get the remote boundary context ID from the environment. + // This way, the user doesn't have to explicitly drill it here. + boundaryId: remoteContext.boundary.id, + }), + Reflect.apply(target, thisArg, args), + ) + }, + }, + ) + }, + () => { + devUtils.error( + 'Failed to enable remote mode: could not connect to the remote server at "%s"', + remoteContext.serverUrl, + ) + }, + ) + + this.beforeRequest = async ({ request }) => { + if (shouldBypassRequest(request)) { + return + } + + // Once the sync server connection is established, prepend the + // remote request handler to be the first for this process. + // This way, the remote process' handlers take priority. + await remoteConnectionPromise + } + } + } + + private forwardLifeCycleEventsToRemote() { + const { remoteClient } = this + + invariant( + remoteClient, + 'Failed to initiate life-cycle events forwarding to the remote: remote client not found. This is likely an issue with MSW. Please report it on GitHub.', + ) + + const events: Array = [ + 'request:start', + 'request:match', + 'request:unhandled', + 'request:end', + 'response:bypass', + 'response:mocked', + 'unhandledException', + ] + + for (const event of events) { + this.emitter.on(event, (args) => { + if ( + !shouldBypassRequest(args.request) && + !args.request.headers.get('accept')?.includes('msw/internal') + ) { + remoteClient.handleLifeCycleEvent({ + type: event, + args, + }) + } + }) + } } } diff --git a/src/node/SetupServerCommonApi.ts b/src/node/SetupServerCommonApi.ts index 0c6050f4c..ad6aabb65 100644 --- a/src/node/SetupServerCommonApi.ts +++ b/src/node/SetupServerCommonApi.ts @@ -17,7 +17,7 @@ import type { RequestHandler } from '~/core/handlers/RequestHandler' import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' import { mergeRight } from '~/core/utils/internal/mergeRight' import { InternalError, devUtils } from '~/core/utils/internal/devUtils' -import type { SetupServerCommon } from './glossary' +import type { ListenOptions, SetupServerCommon } from './glossary' import { handleWebSocketEvent } from '~/core/ws/handleWebSocketEvent' import { webSocketInterceptor } from '~/core/ws/webSocketInterceptor' import { isHandlerKind } from '~/core/utils/internal/isHandlerKind' @@ -34,7 +34,7 @@ export class SetupServerCommonApi Array>, HttpRequestEventMap > - private resolvedOptions: RequiredDeep + protected resolvedOptions: RequiredDeep constructor( interceptors: Array<{ new (): Interceptor }>, @@ -47,7 +47,17 @@ export class SetupServerCommonApi interceptors: interceptors.map((Interceptor) => new Interceptor()), }) - this.resolvedOptions = {} as RequiredDeep + this.resolvedOptions = {} as RequiredDeep + } + + protected async beforeRequest( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + args: { + requestId: string + request: Request + }, + ): Promise { + return Promise.resolve() } /** @@ -57,6 +67,8 @@ export class SetupServerCommonApi this.interceptor.on( 'request', async ({ request, requestId, controller }) => { + await this.beforeRequest({ requestId, request }) + const response = await handleRequest( request, requestId, @@ -132,11 +144,11 @@ export class SetupServerCommonApi }) } - public listen(options: Partial = {}): void { + public listen(options: Partial = {}): void { this.resolvedOptions = mergeRight( DEFAULT_LISTEN_OPTIONS, options, - ) as RequiredDeep + ) as RequiredDeep // Apply the interceptor when starting the server. // Attach the event listeners to the interceptor here diff --git a/src/node/glossary.ts b/src/node/glossary.ts index 7f52c9f91..46daf4734 100644 --- a/src/node/glossary.ts +++ b/src/node/glossary.ts @@ -7,13 +7,27 @@ import type { SharedOptions, } from '~/core/sharedOptions' +export interface ListenOptions extends SharedOptions { + /** + * Enable remote request resolution. + * + * With `remote` set to `true`, all the outgoing requests in this process + * will be forwarded to a remote process where `setupRemoteServer` was + * created to handle. If the remote process hasn't handled the request, + * it will be handled by whichever request handlers you have in this process. + */ + remote?: { + enabled: boolean + } +} + export interface SetupServerCommon { /** * Starts requests interception based on the previously provided request handlers. * * @see {@link https://mswjs.io/docs/api/setup-server/listen `server.listen()` API reference} */ - listen(options?: PartialDeep): void + listen(options?: PartialDeep): void /** * Stops requests interception by restoring all augmented modules. diff --git a/src/node/index.ts b/src/node/index.ts index d9b2ea46c..363da30a6 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -1,3 +1,9 @@ -export type { SetupServer } from './glossary' +export type { SetupServer, ListenOptions } from './glossary' export { SetupServerApi } from './SetupServerApi' export { setupServer } from './setupServer' +export { + SetupRemoteServer, + SetupRemoteServerApi, + setupRemoteServer, +} from './setupRemoteServer' +export { getRemoteEnvironment } from './remoteContext' diff --git a/src/node/remoteContext.ts b/src/node/remoteContext.ts new file mode 100644 index 000000000..6e4a10e04 --- /dev/null +++ b/src/node/remoteContext.ts @@ -0,0 +1,58 @@ +import { invariant } from 'outvariant' +import { remoteHandlersContext } from './setupRemoteServer' + +export const MSW_REMOTE_SERVER_URL = 'MSW_REMOTE_SERVER_URL' +export const MSW_REMOTE_BOUNDARY_ID = 'MSW_REMOTE_BOUNDARY_ID' + +export interface RemoteContext { + serverUrl: URL + boundary: { + id: string + } +} + +export function getRemoteContext(): RemoteContext { + const store = remoteHandlersContext.getStore() + + invariant( + store, + 'Failed to retrieve remote context: no context found. Did you forget to call this within a `remote.boundary()`?', + ) + + return { + serverUrl: store.serverUrl, + boundary: { + id: store.boundaryId, + }, + } +} + +export function getRemoteContextFromEnvironment(): RemoteContext { + const serverUrl = process.env[MSW_REMOTE_SERVER_URL] + const boundaryId = process.env[MSW_REMOTE_BOUNDARY_ID] + + invariant( + serverUrl, + 'Failed to enable remote mode: server URL is missing in the environment', + ) + invariant( + boundaryId, + 'Failed to enable remote mode: boundary ID is missing in the environment', + ) + + return { + serverUrl: new URL(serverUrl), + boundary: { + id: boundaryId, + }, + } +} + +export function getRemoteEnvironment() { + const remoteContext = getRemoteContext() + + return { + [MSW_REMOTE_SERVER_URL]: remoteContext.serverUrl.toString(), + [MSW_REMOTE_BOUNDARY_ID]: remoteContext.boundary.id, + } +} diff --git a/src/node/setupRemoteServer.ts b/src/node/setupRemoteServer.ts new file mode 100644 index 000000000..f7dfcc166 --- /dev/null +++ b/src/node/setupRemoteServer.ts @@ -0,0 +1,404 @@ +import * as http from 'node:http' +import { Readable } from 'node:stream' +import * as streamConsumers from 'node:stream/consumers' +import { AsyncLocalStorage } from 'node:async_hooks' +import type { RequiredDeep } from 'type-fest' +import { invariant } from 'outvariant' +import { createRequestId } from '@mswjs/interceptors' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { Emitter } from 'strict-event-emitter' +import { SetupApi } from '~/core/SetupApi' +import type { RequestHandler } from '~/core/handlers/RequestHandler' +import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler' +import { handleRequest } from '~/core/utils/handleRequest' +import { isHandlerKind } from '~/core/utils/internal/isHandlerKind' +import type { + LifeCycleEventEmitter, + LifeCycleEventsMap, +} from '~/core/sharedOptions' +import { devUtils } from '~/core/utils/internal/devUtils' +import { AsyncHandlersController } from './SetupServerApi' +import { ListenOptions } from './glossary' +import { mergeRight } from '~/core/utils/internal/mergeRight' +import { DEFAULT_LISTEN_OPTIONS } from './SetupServerCommonApi' +import { onUnhandledRequest } from '~/core/utils/request/onUnhandledRequest' +import { + type ForwardedLifeCycleEventPayload, + deserializeError, + deserializeFetchRequest, + deserializeFetchResponse, +} from '~/core/RemoteClient' + +interface RemoteServerBoundaryContext { + serverUrl: URL + boundaryId: string + initialHandlers: Array + handlers: Array +} + +export const remoteHandlersContext = + new AsyncLocalStorage() + +const REMOTE_SERVER_HOSTNAME = 'localhost' + +const kRemoteServer = Symbol('kRemoteServer') + +/** + * Enables API mocking in a remote Node.js process. + * + * @see {@link https://mswjs.io/docs/api/setup-remote-server `setupRemoteServer()` API reference} + */ +export function setupRemoteServer( + ...handlers: Array +): SetupRemoteServerApi { + return new SetupRemoteServerApi(handlers) +} + +export interface SetupRemoteServer { + events: LifeCycleEventEmitter + get boundaryId(): string + + listen: () => Promise + + boundary: , R>( + callback: (...args: Args) => R, + ) => (...args: Args) => R + + close: () => Promise +} + +const kServerUrl = Symbol('kServerUrl') + +export class SetupRemoteServerApi + extends SetupApi + implements SetupRemoteServer +{ + [kServerUrl]: URL | undefined + + protected resolvedOptions!: RequiredDeep + protected executionContexts: Map RemoteServerBoundaryContext> + + constructor(handlers: Array) { + super(...handlers) + + this.handlersController = new AsyncHandlersController({ + storage: remoteHandlersContext, + initialHandlers: handlers, + }) + + this.executionContexts = new Map() + } + + get serverUrl(): URL { + invariant( + this[kServerUrl], + 'Failed to get a remote port in `setupRemoteServer`. Did you forget to `await remote.listen()`?', + ) + + return this[kServerUrl] + } + + get boundaryId(): string { + const context = remoteHandlersContext.getStore() + + invariant( + context != null, + 'Failed to get "contextId" on "SetupRemoteServerApi": no context found. Did you forget to wrap this closure in `remote.boundary()`?', + ) + + return context.boundaryId + } + + public async listen(options: Partial = {}): Promise { + this.resolvedOptions = mergeRight( + DEFAULT_LISTEN_OPTIONS, + options, + ) as RequiredDeep + const dummyEmitter = new Emitter() + + const server = await createSyncServer() + this[kServerUrl] = getServerUrl(server) + + process + .once('SIGTERM', () => closeSyncServer(server)) + .once('SIGINT', () => closeSyncServer(server)) + + // Close the server if the setup API is disposed. + this.subscriptions.push(() => closeSyncServer(server)) + + server.on('request', async (incoming, outgoing) => { + if (!incoming.method) { + return + } + + // Handle the handshake request from the client. + if (incoming.method === 'HEAD') { + outgoing.writeHead(200).end() + return + } + + // Handle life-cycle event requests forwarded from `setupServer`. + if (incoming.url === '/life-cycle-events') { + this.handleLifeCycleEventRequest(incoming, outgoing) + return + } + + const requestId = incoming.headers['x-msw-request-id'] + const requestUrl = incoming.headers['x-msw-request-url'] + const contextId = incoming.headers['x-msw-boundary-id'] + + if (typeof requestId !== 'string') { + outgoing.writeHead(400) + outgoing.end('Expected the "x-msw-request-id" header to be a string') + return + } + + if (typeof requestUrl !== 'string') { + outgoing.writeHead(400) + outgoing.end('Expected the "x-msw-request-url" header to be a string') + return + } + + // Validate remote context id. + if (contextId != null && typeof contextId !== 'string') { + outgoing.writeHead(400) + outgoing.end( + `Expected the "contextId" value to be a string but got ${typeof contextId}`, + ) + return + } + + const request = new Request(requestUrl, { + method: incoming.method, + body: + incoming.method !== 'HEAD' && incoming.method !== 'GET' + ? (Readable.toWeb(incoming) as ReadableStream) + : null, + // @ts-expect-error Missing Node.js types. + duplex: 'half' + }) + + for (const headerName in incoming.headersDistinct) { + const headerValue = incoming.headersDistinct[headerName] + if (headerValue) { + headerValue.forEach((value) => { + request.headers.append(headerName, value) + }) + } + } + + const handlers = this.resolveHandlers({ contextId }).filter( + /** @todo Eventually allow all handler types */ + isHandlerKind('RequestHandler'), + ) + const response = await handleRequest( + request, + requestId, + handlers, + { + /** + * @note Ignore the `onUnhandledRequest` callback during the + * request handling. This context isn't the only one handling + * the request. Instead, this logic is moved to the forwarded + * life-cycle event. + */ + onUnhandledRequest() {}, + }, + /** + * @note Use a dummy emitter because this context + * is only one layer that can resolve a request. For example, + * request can be resolved in the remote process and not here. + */ + dummyEmitter, + ) + + if (response) { + outgoing.writeHead( + response.status, + response.statusText, + Array.from(response.headers), + ) + + if (response.body) { + Readable.fromWeb(response.body as any).pipe(outgoing) + } else { + outgoing.end() + } + + return + } + + outgoing.writeHead(404).end() + }) + + this.emitter.on('request:unhandled', async ({ request }) => { + /** + * @note React to unhandled requests in the "request:unhandled" listener. + * This event will be forwarded from the remote process after neither has + * handled the request. + */ + await onUnhandledRequest(request, this.resolvedOptions.onUnhandledRequest) + }) + } + + public boundary, R>( + callback: (...args: Args) => R, + ): (...args: Args) => R { + const boundaryId = createRequestId() + + return (...args: Args): R => { + const context = { + serverUrl: this.serverUrl, + boundaryId, + initialHandlers: this.handlersController.currentHandlers(), + handlers: [], + } satisfies RemoteServerBoundaryContext + + this.executionContexts.set(boundaryId, () => context) + return remoteHandlersContext.run(context, callback, ...args) + } + } + + public async close(): Promise { + this.executionContexts.clear() + remoteHandlersContext.disable() + + const syncServer = Reflect.get(globalThis, kRemoteServer) + + invariant( + syncServer, + devUtils.formatMessage( + 'Failed to close a remote server: no server is running. Did you forget to call and await ".listen()"?', + ), + ) + + await closeSyncServer(syncServer) + } + + private resolveHandlers(args: { + contextId: string | undefined + }): Array { + const defaultHandlers = this.handlersController.currentHandlers() + + // Request that are not bound to a remote context id + // cannot be affected by the handlers from that context. + // Return the list of current process handlers instead. + if (!args.contextId) { + return defaultHandlers + } + + invariant( + this.executionContexts.has(args.contextId), + 'Failed to handle a remote request: no context found by id "%s"', + args.contextId, + ) + + // If the request event has a context associated with it, + // look up the current state of that context to get the handlers. + const getContext = this.executionContexts.get(args.contextId) + + invariant( + getContext != null, + 'Failed to handle a remote request: the context by id "%s" is empty', + args.contextId, + ) + + return getContext().handlers + } + + private async handleLifeCycleEventRequest( + incoming: http.IncomingMessage, + outgoing: http.ServerResponse & { + req: http.IncomingMessage + }, + ) { + const event = (await streamConsumers.json( + incoming, + )) as ForwardedLifeCycleEventPayload + + invariant( + event.type, + 'Failed to emit a forwarded life-cycle event: request payload corrupted', + ) + + // Emit the forwarded life-cycle event on this emitter. + this.emitter.emit(event.type as any, { + requestId: event.args.requestId, + request: deserializeFetchRequest(event.args.request), + response: + event.args.response != null + ? deserializeFetchResponse(event.args.response) + : undefined, + error: + event.args.error != null + ? deserializeError(event.args.error) + : undefined, + }) + + outgoing.writeHead(200).end() + } +} + +/** + * Creates an internal HTTP server. + */ +async function createSyncServer(): Promise { + const syncServer = Reflect.get(globalThis, kRemoteServer) + + // Reuse the existing WebSocket server reference if it exists. + // It persists on the global scope between hot updates. + if (syncServer) { + return syncServer + } + + const serverReadyPromise = new DeferredPromise() + const server = http.createServer() + + server.listen(0, REMOTE_SERVER_HOSTNAME, async () => { + serverReadyPromise.resolve(server) + }) + + server.once('error', (error) => { + serverReadyPromise.reject(error) + Reflect.deleteProperty(globalThis, kRemoteServer) + }) + + Object.defineProperty(globalThis, kRemoteServer, { + value: server, + }) + + return serverReadyPromise +} + +function getServerUrl(server: http.Server): URL { + const address = server.address() + + invariant(address, 'Failed to get server URL: server address is not defined') + + if (typeof address === 'string') { + return new URL(address) + } + + return new URL(`http://${REMOTE_SERVER_HOSTNAME}:${address.port}`) +} + +async function closeSyncServer(server: http.Server): Promise { + if (!server.listening) { + return Promise.resolve() + } + + const serverClosePromise = new DeferredPromise() + + server.close((error) => { + if (error) { + serverClosePromise.reject(error) + return + } + + serverClosePromise.resolve() + }) + + await serverClosePromise.then(() => { + Reflect.deleteProperty(globalThis, kRemoteServer) + }) +} diff --git a/src/node/setupServer.ts b/src/node/setupServer.ts index cb2ee7ec4..e662b533c 100644 --- a/src/node/setupServer.ts +++ b/src/node/setupServer.ts @@ -4,7 +4,6 @@ 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()` API reference} */ diff --git a/src/tsconfig.core.build.json b/src/tsconfig.core.build.json index 0852a1d01..06edbfc21 100644 --- a/src/tsconfig.core.build.json +++ b/src/tsconfig.core.build.json @@ -1,5 +1,5 @@ { - "extends": "./tsconfig.src.json", + "extends": "./tsconfig.core.json", "compilerOptions": { "composite": false } diff --git a/src/tsconfig.core.json b/src/tsconfig.core.json new file mode 100644 index 000000000..11cc1e95c --- /dev/null +++ b/src/tsconfig.core.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["../global.d.ts", "./core"], + "references": [{ "path": "./tsconfig.node.json" }], + "exclude": ["**/*.test.ts"], + "compilerOptions": { + "composite": true + } +} diff --git a/src/tsconfig.node.json b/src/tsconfig.node.json index c98a860ae..062f2b23f 100644 --- a/src/tsconfig.node.json +++ b/src/tsconfig.node.json @@ -1,8 +1,15 @@ { - "extends": "./tsconfig.src.json", + "extends": "../tsconfig.base.json", + "include": ["../global.d.ts", "./node", "./native"], + "references": [{ "path": "./tsconfig.core.json" }], + "exclude": ["**/*.test.ts"], "compilerOptions": { - "types": ["node"] - }, - "include": ["./node", "./native"], - "exclude": ["**/*.test.ts"] + "composite": true, + "types": ["node"], + "baseUrl": "./", + "paths": { + "~/core": ["core"], + "~/core/*": ["core/*"] + } + } } diff --git a/src/tsconfig.src.json b/src/tsconfig.src.json deleted file mode 100644 index 1399d3535..000000000 --- a/src/tsconfig.src.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - // Common configuration for everything - // living in the "src" directory. - "extends": "../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "baseUrl": "./", - "paths": { - "~/core": ["core"], - "~/core/*": ["core/*"] - } - }, - "include": ["../global.d.ts", "./**/*.ts"], - "exclude": ["./**/*.test.ts"] -} diff --git a/test/node/msw-api/setup-remote-server/life-cycle-event-forwarding.node.test.ts b/test/node/msw-api/setup-remote-server/life-cycle-event-forwarding.node.test.ts new file mode 100644 index 000000000..9831e88ba --- /dev/null +++ b/test/node/msw-api/setup-remote-server/life-cycle-event-forwarding.node.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment node +import { http, HttpResponse } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { HttpServer } from '@open-draft/test-server/http' +import { spyOnLifeCycleEvents } from '../../utils' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +const httpServer = new HttpServer((app) => { + app.get('/greeting', (req, res) => { + res.send('hello') + }) +}) + +beforeAll(async () => { + await remote.listen() + await httpServer.listen() +}) + +afterAll(async () => { + await remote.close() + await httpServer.close() +}) + +it( + 'emits correct events for the request handled in the test process', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.json({ mocked: true }) + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + const { listener, requestIdPromise } = spyOnLifeCycleEvents(remote) + + const response = await fetch(new URL('/resource', testApp.url)) + const requestId = await requestIdPromise + + // Must respond with the mocked response defined in the test. + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.json()).resolves.toEqual({ mocked: true }) + + // Must forward the life-cycle events to the test process. + await vi.waitFor(() => { + expect(listener.mock.calls).toEqual([ + [`[request:start] GET https://example.com/resource ${requestId}`], + [`[request:match] GET https://example.com/resource ${requestId}`], + [`[request:end] GET https://example.com/resource ${requestId}`], + [ + `[response:mocked] GET https://example.com/resource ${requestId} 200 {"mocked":true}`, + ], + ]) + }) + }), +) + +it( + 'emits correct events for the request handled in the remote process', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + const { listener, requestIdPromise } = spyOnLifeCycleEvents(remote) + + const response = await fetch(new URL('/resource', testApp.url)) + const requestId = await requestIdPromise + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.json()).resolves.toEqual([1, 2, 3]) + + await vi.waitFor(() => { + expect(listener.mock.calls).toEqual([ + [`[request:start] GET https://example.com/resource ${requestId}`], + [`[request:match] GET https://example.com/resource ${requestId}`], + [`[request:end] GET https://example.com/resource ${requestId}`], + [ + `[response:mocked] GET https://example.com/resource ${requestId} 200 [1,2,3]`, + ], + ]) + }) + }), +) + +it( + 'emits correct events for the request unhandled by either parties', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + const { listener, requestIdPromise } = spyOnLifeCycleEvents(remote) + + const resourceUrl = httpServer.http.url('/greeting') + // Request a special route in the running app that performs a proxy request + // to the resource specified in the "Location" request header. + const response = await fetch(new URL('/proxy', testApp.url), { + headers: { + location: resourceUrl, + }, + }) + const requestId = await requestIdPromise + + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + expect(await response.text()).toEqual('hello') + + await vi.waitFor(() => { + expect(listener.mock.calls).toEqual([ + [`[request:start] GET ${resourceUrl} ${requestId}`], + [`[request:unhandled] GET ${resourceUrl} ${requestId}`], + [`[request:end] GET ${resourceUrl} ${requestId}`], + [`[response:bypass] GET ${resourceUrl} ${requestId} 200 hello`], + ]) + }) + }), +) diff --git a/test/node/msw-api/setup-remote-server/on-unhandled-request-bypass.test.ts b/test/node/msw-api/setup-remote-server/on-unhandled-request-bypass.test.ts new file mode 100644 index 000000000..52d712b48 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/on-unhandled-request-bypass.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment node +import { http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + await remote.listen({ + onUnhandledRequest: 'bypass', + }) +}) + +afterEach(() => { + remote.resetHandlers() +}) + +afterAll(async () => { + vi.restoreAllMocks() + await remote.close() +}) + +it( + 'does not error on the request not handled here and there', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js'), { + onUnhandledRequest: 'bypass', + }) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/unhandled', + }, + }) + + const unhandledErrorPromise = vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + + • GET http://localhost/unhandled + +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`) + }) + + await expect(unhandledErrorPromise).rejects.toThrow() + expect(console.error).not.toHaveBeenCalled() + }), +) + +it( + 'does not error on the request handled here', + remote.boundary(async () => { + remote.use( + http.get('http://localhost/handled', () => { + return new Response('handled') + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js'), { + onUnhandledRequest: 'bypass', + }) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/handled', + }, + }) + + const unhandledErrorPromise = vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + +• GET http://localhost/handled + +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`) + }) + + await expect(unhandledErrorPromise).rejects.toThrow() + expect(console.error).not.toHaveBeenCalled() + }), +) + +it( + 'does not error on the request handled there', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js'), { + onUnhandledRequest: 'bypass', + }) + + await fetch(new URL('/resource', testApp.url)) + + const unhandledErrorPromise = vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + +• GET https://example.com/resource + +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`) + }) + + await expect(unhandledErrorPromise).rejects.toThrow() + expect(console.error).not.toHaveBeenCalled() + }), +) diff --git a/test/node/msw-api/setup-remote-server/on-unhandled-request-callback.test.ts b/test/node/msw-api/setup-remote-server/on-unhandled-request-callback.test.ts new file mode 100644 index 000000000..92d6a5334 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/on-unhandled-request-callback.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node +import { http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() +const onUnhandledRequestCallback = vi.fn() + +beforeAll(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await remote.listen({ + onUnhandledRequest: onUnhandledRequestCallback, + }) +}) + +afterEach(() => { + vi.clearAllMocks() + remote.resetHandlers() +}) + +afterAll(async () => { + vi.restoreAllMocks() + await remote.close() +}) + +it( + 'calls the custom callback on the request not handled here and there', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js'), { + onUnhandledRequest: 'bypass', + }) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/unhandled', + }, + }) + + await vi.waitFor(() => { + expect(onUnhandledRequestCallback).toHaveBeenCalledOnce() + }) + expect(console.warn).not.toHaveBeenCalled() + }), +) + +it( + 'does not call the custom callback on the request handled here', + remote.boundary(async () => { + remote.use( + http.get('http://localhost/handled', () => { + return new Response('handled') + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js'), { + onUnhandledRequest: 'bypass', + }) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/handled', + }, + }) + + const unhandledCallbackPromise = vi.waitFor(() => { + expect(onUnhandledRequestCallback).toHaveBeenCalledOnce() + }) + + await expect(unhandledCallbackPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) + +it( + 'does not call the custom callback on the request handled there', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js'), { + onUnhandledRequest: 'bypass', + }) + + await fetch(new URL('/resource', testApp.url)) + + const unhandledCallbackPromise = vi.waitFor(() => { + expect(onUnhandledRequestCallback).toHaveBeenCalledOnce() + }) + + await expect(unhandledCallbackPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) diff --git a/test/node/msw-api/setup-remote-server/on-unhandled-request-default.test.ts b/test/node/msw-api/setup-remote-server/on-unhandled-request-default.test.ts new file mode 100644 index 000000000..7e15b1fc9 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/on-unhandled-request-default.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment node +import { http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + /** + * @note Console warnings from the app's context are forwarded + * as `console.error`. Ignore those for this test. + */ + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await remote.listen() +}) + +afterEach(() => { + vi.clearAllMocks() + remote.resetHandlers() +}) + +afterAll(async () => { + vi.restoreAllMocks() + await remote.close() +}) + +it( + 'warns on requests not handled by either party be default', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + // Hit a special endpoint that will perform a request to "Location" + // in the application's context. Neither party handles this request. + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/unhandled', + }, + }) + + // Awaiting the unhandled life-cycle event from the app process takes time. + await vi.waitFor(() => { + // Must print a warning since nobody has handled the request. + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + + • GET http://localhost/unhandled + +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`) + }) + }), +) + +it( + 'does not warn on the request handled here', + remote.boundary(async () => { + remote.use( + http.get('http://localhost/handled', () => { + return new Response('handled') + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + // Hit a special endpoint that will perform a request to "Location" + // in the application's context. Neither party handles this request. + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/handled', + }, + }) + + const unhandledWarningPromise = vi.waitFor(() => { + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + +• GET http://localhost/handled + +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`) + }) + + await expect(unhandledWarningPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) + +it( + 'does not warn on the request not handled here but handled there', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + await fetch(new URL('/resource', testApp.url)) + + const unhandledWarningPromise = vi.waitFor(() => { + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + +• GET https://example.com/resource + +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`) + }) + + await expect(unhandledWarningPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) diff --git a/test/node/msw-api/setup-remote-server/on-unhandled-request-error.test.ts b/test/node/msw-api/setup-remote-server/on-unhandled-request-error.test.ts new file mode 100644 index 000000000..a266465a6 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/on-unhandled-request-error.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +import { http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + await remote.listen({ + onUnhandledRequest: 'error', + }) +}) + +afterEach(() => { + vi.clearAllMocks() + remote.resetHandlers() +}) + +afterAll(async () => { + vi.restoreAllMocks() + await remote.close() +}) + +it( + 'errors on the request not handled here and there', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/unhandled', + }, + }) + + await vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + + • GET http://localhost/unhandled + +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`) + }) + }), +) + +it( + 'does not error on the request handled here', + remote.boundary(async () => { + remote.use( + http.get('http://localhost/handled', () => { + return new Response('handled') + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/handled', + }, + }) + + const unhandledErrorPromise = vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + +• GET http://localhost/handled + +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`) + }) + + await expect(unhandledErrorPromise).rejects.toThrow() + expect(console.error).not.toHaveBeenCalled() + }), +) + +it( + 'does not error on the request handled there', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + await fetch(new URL('/resource', testApp.url)) + + const unhandledErrorPromise = vi.waitFor(() => { + expect(console.error).toHaveBeenCalledWith(`\ +[MSW] Error: intercepted a request without a matching request handler: + +• GET https://example.com/resource + +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`) + }) + + await expect(unhandledErrorPromise).rejects.toThrow() + expect(console.error).not.toHaveBeenCalled() + }), +) diff --git a/test/node/msw-api/setup-remote-server/on-unhandled-request-warn.test.ts b/test/node/msw-api/setup-remote-server/on-unhandled-request-warn.test.ts new file mode 100644 index 000000000..c7502e597 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/on-unhandled-request-warn.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment node +import { http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + /** + * @note Console warnings from the app's context are forwarded + * as `console.error`. Ignore those for this test. + */ + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await remote.listen({ + onUnhandledRequest: 'warn', + }) +}) + +afterEach(() => { + vi.clearAllMocks() + remote.resetHandlers() +}) + +afterAll(async () => { + vi.restoreAllMocks() + await remote.close() +}) + +it( + 'warns on the request not handled here and there', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/unhandled', + }, + }) + + await vi.waitFor(() => { + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + + • GET http://localhost/unhandled + +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`) + }) + }), +) + +it( + 'does not warn on the request handled here', + remote.boundary(async () => { + remote.use( + http.get('http://localhost/handled', () => { + return new Response('handled') + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + await fetch(new URL('/proxy', testApp.url), { + headers: { + location: 'http://localhost/handled', + }, + }) + + const unhandledWarningPromise = vi.waitFor(() => { + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + +• GET http://localhost/handled + +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`) + }) + + await expect(unhandledWarningPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) + +it( + 'does not warn on the request handled there', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + await fetch(new URL('/resource', testApp.url)) + + const unhandledWarningPromise = vi.waitFor(() => { + expect(console.warn).toHaveBeenCalledWith(`\ +[MSW] Warning: intercepted a request without a matching request handler: + +• GET https://example.com/resource + +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`) + }) + + await expect(unhandledWarningPromise).rejects.toThrow() + expect(console.warn).not.toHaveBeenCalled() + }), +) diff --git a/test/node/msw-api/setup-remote-server/remote-boundary.test.ts b/test/node/msw-api/setup-remote-server/remote-boundary.test.ts new file mode 100644 index 000000000..618ef05fb --- /dev/null +++ b/test/node/msw-api/setup-remote-server/remote-boundary.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment node +import { HttpResponse, http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + await remote.listen() +}) + +afterEach(() => { + remote.resetHandlers() +}) + +afterAll(async () => { + await remote.close() +}) + +it.concurrent( + 'uses initial handlers if the boundary has no overrides', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + + const json = await response.json() + expect(json).toEqual([1, 2, 3]) + }), +) + +it.concurrent( + 'uses runtime request handlers declared in the boundary', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.json({ mocked: true }) + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + + const json = await response.json() + expect(json).toEqual({ mocked: true }) + }), +) diff --git a/test/node/msw-api/setup-remote-server/response.body.test.ts b/test/node/msw-api/setup-remote-server/response.body.test.ts new file mode 100644 index 000000000..5e447e32b --- /dev/null +++ b/test/node/msw-api/setup-remote-server/response.body.test.ts @@ -0,0 +1,137 @@ +// @vitest-environment node +import { http, HttpResponse } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + await remote.listen() +}) + +afterAll(async () => { + await remote.close() +}) + +it( + 'supports responding to a remote request with text', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.text('hello world') + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + await expect(response.text()).resolves.toBe('hello world') + }), +) + +it( + 'supports responding to a remote request with JSON', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.json({ hello: 'world' }) + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ hello: 'world' }) + }), +) + +it( + 'supports responding to a remote request with ArrayBuffer', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.arrayBuffer( + new TextEncoder().encode('hello world').buffer, + ) + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + const response = await fetch(new URL('/resource', testApp.url)) + const buffer = await response.arrayBuffer() + + expect(response.status).toBe(200) + expect(new TextDecoder().decode(buffer)).toBe('hello world') + }), +) + +it( + 'supports responding to a remote request with Blob', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return new Response(new Blob(['hello world'])) + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + await expect(response.blob()).resolves.toEqual(new Blob(['hello world'])) + }), +) + +it( + 'supports responding to a remote request with FormData', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + const formData = new FormData() + formData.append('hello', 'world') + return HttpResponse.formData(formData) + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + + await expect(response.text()).resolves.toMatch( + /^------formdata-undici-\d{12}\r\nContent-Disposition: form-data; name="hello"\r\n\r\nworld\r\n------formdata-undici-\d{12}--$/, + ) + }), +) + +it( + 'supports responding to a remote request with ReadableStream', + remote.boundary(async () => { + const encoder = new TextEncoder() + remote.use( + http.get('https://example.com/resource', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('hello')) + controller.enqueue(encoder.encode(' ')) + controller.enqueue(encoder.encode('world')) + controller.close() + }, + }) + return new Response(stream, { + headers: { 'Content-Type': 'text/plain' }, + }) + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('hello world') + }), +) diff --git a/test/node/msw-api/setup-remote-server/use.app.js b/test/node/msw-api/setup-remote-server/use.app.js new file mode 100644 index 000000000..ee623085d --- /dev/null +++ b/test/node/msw-api/setup-remote-server/use.app.js @@ -0,0 +1,70 @@ +const { Readable } = require('node:stream') +const express = require('express') +const { http, HttpResponse } = require('msw') +const { setupServer } = require('msw/node') + +const { SETUP_SERVER_LISTEN_OPTIONS } = process.env + +// Enable API mocking as usual. +const server = setupServer( + http.get('https://example.com/resource', () => { + return HttpResponse.json([1, 2, 3]) + }), +) + +server.listen({ + ...(SETUP_SERVER_LISTEN_OPTIONS + ? JSON.parse(SETUP_SERVER_LISTEN_OPTIONS) + : {}), + remote: { + enabled: true, + }, +}) + +// Spawn a Node.js application. +const app = express() + +app.get('/resource', async (req, res) => { + const response = await fetch('https://example.com/resource') + res.writeHead(response.status, response.statusText) + Readable.fromWeb(response.body).pipe(res) +}) + +app.use('/proxy', async (req, res) => { + const response = await fetch(req.header('location'), { + method: req.method, + headers: req.headers, + }) + res.writeHead(response.status, response.statusText) + + if (response.body) { + const reader = response.body.getReader() + reader.read().then(function processResult(result) { + if (result.done) { + res.end() + return + } + + res.write(Buffer.from(result.value)) + reader.read().then(processResult) + }) + } else { + res.end() + } +}) + +const httpServer = app.listen(0, () => { + if (!process.send) { + throw new Error( + 'Failed to start a test Node.js app: not spawned as a child process of the test', + ) + } + + const address = httpServer.address() + + if (typeof address === 'string') { + return process.send(address) + } + + process.send(new URL(`http://localhost:${address.port}`).href) +}) diff --git a/test/node/msw-api/setup-remote-server/use.node.test.ts b/test/node/msw-api/setup-remote-server/use.node.test.ts new file mode 100644 index 000000000..5559031b2 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/use.node.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node +import { HttpResponse, http } from 'msw' +import { setupRemoteServer } from 'msw/node' +import { spawnTestApp } from './utils' + +const remote = setupRemoteServer() + +beforeAll(async () => { + await remote.listen() +}) + +afterAll(async () => { + await remote.close() +}) + +it( + 'returns a mocked response defined in the app by default', + remote.boundary(async () => { + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + + const json = await response.json() + expect(json).toEqual([1, 2, 3]) + }), +) + +it( + 'returns a mocked response from the matching runtime request handler', + remote.boundary(async () => { + remote.use( + http.get('https://example.com/resource', () => { + return HttpResponse.json({ mocked: true }) + }), + ) + + await using testApp = await spawnTestApp(require.resolve('./use.app.js')) + + const response = await fetch(new URL('/resource', testApp.url)) + expect(response.status).toBe(200) + expect(response.statusText).toBe('OK') + + const json = await response.json() + expect(json).toEqual({ mocked: true }) + }), +) diff --git a/test/node/msw-api/setup-remote-server/utils.ts b/test/node/msw-api/setup-remote-server/utils.ts new file mode 100644 index 000000000..c5ed36f70 --- /dev/null +++ b/test/node/msw-api/setup-remote-server/utils.ts @@ -0,0 +1,85 @@ +import { invariant } from 'outvariant' +import { spawn } from 'child_process' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { type ListenOptions, getRemoteEnvironment } from 'msw/node' + +export async function spawnTestApp( + appSourcePath: string, + listenOptions: Partial = {}, +) { + let url: string | undefined + const spawnPromise = new DeferredPromise().then((resolvedUrl) => { + url = resolvedUrl + }) + + const io = spawn('node', [appSourcePath], { + // Establish an IPC between the test and the test app. + // This IPC is not required for the remote interception to work. + // This IPC is required for the test app to be spawned at a random port + // and be able to communicate the port back to the test. + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: { + ...process.env, + ...getRemoteEnvironment(), + SETUP_SERVER_LISTEN_OPTIONS: JSON.stringify(listenOptions), + }, + }) + + io.stdout?.on('data', (data) => console.log(data.toString())) + io.stderr?.on('data', (data) => console.error(data.toString())) + + io.on('message', (message) => { + try { + const url = new URL(message.toString()) + spawnPromise.resolve(url.href) + } catch (error) { + return + } + }) + .on('error', (error) => spawnPromise.reject(error)) + .on('exit', (code) => { + if (code !== 0) { + spawnPromise.reject( + new Error(`Failed to spawn a test Node app (exit code: ${code})`), + ) + } + }) + + await Promise.race([ + spawnPromise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('Failed to spawn a test Node app within timeout')) + }, 5000) + }), + ]) + + return { + get url() { + invariant( + url, + 'Failed to return the URL for the test Node app: the app is not running. Did you forget to call ".spawn()"?', + ) + + return url + }, + + async [Symbol.asyncDispose]() { + if (io.exitCode !== null) { + return Promise.resolve() + } + + const closePromise = new DeferredPromise() + + io.send('SIGTERM', (error) => { + if (error) { + closePromise.reject(error) + } else { + closePromise.resolve() + } + }) + + await closePromise + }, + } +} diff --git a/test/node/msw-api/setup-server/life-cycle-events/ignore-internal-requests.test.ts b/test/node/msw-api/setup-server/life-cycle-events/ignore-internal-requests.test.ts new file mode 100644 index 000000000..c9341310b --- /dev/null +++ b/test/node/msw-api/setup-server/life-cycle-events/ignore-internal-requests.test.ts @@ -0,0 +1,34 @@ +// @vitest-environment node +import { setupServer } from 'msw/node' +import { spyOnLifeCycleEvents } from '../../../utils' + +const server = setupServer() + +beforeAll(() => { + // Mock the environment variables required for the remote interception to work. + vi.stubEnv('MSW_REMOTE_SERVER_URL', 'http://localhost/noop') + vi.stubEnv('MSW_REMOTE_BOUNDARY_ID', 'abc-123') + + server.listen({ + // Enable remote interception to trigger internal requests. + // The connection is meant to fail here. + remote: { + enabled: true, + }, + }) +}) + +afterEach(() => { + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +it('does not emit life-cycle events for internal requests', async () => { + const { listener } = spyOnLifeCycleEvents(server) + + // Must emit no life-cycle events for internal requests. + expect(listener).not.toHaveBeenCalled() +}) 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 30e4f0a5d..eb531f8da 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 @@ -17,7 +17,7 @@ beforeAll(() => { }) afterEach(() => { - vi.resetAllMocks() + vi.clearAllMocks() }) afterAll(() => { @@ -41,11 +41,4 @@ If you still wish to intercept this unhandled request, please create a request h Read more: https://mswjs.io/docs/getting-started/mocks`) }) -it('does not warn on unhandled "file://" requests', async () => { - // This request is expected to fail: - // Fetching non-existing file URL. - await fetch('file:///file/does/not/exist').catch(() => void 0) - - expect(console.error).not.toBeCalled() - expect(console.warn).not.toBeCalled() -}) +it.todo('does not warn on unhandled "file://" requests') diff --git a/test/node/tsconfig.json b/test/node/tsconfig.json index 8c9ede84d..fa2c8107a 100644 --- a/test/node/tsconfig.json +++ b/test/node/tsconfig.json @@ -17,5 +17,5 @@ "allowSyntheticDefaultImports": true, "types": ["node", "vitest/globals"] }, - "include": ["../../global.d.ts", "./**/*.test.ts"] + "include": ["./**/*.test.ts"] } diff --git a/test/node/utils.ts b/test/node/utils.ts new file mode 100644 index 000000000..84c64843c --- /dev/null +++ b/test/node/utils.ts @@ -0,0 +1,58 @@ +import { DeferredPromise } from '@open-draft/deferred-promise' +import { vi, afterEach } from 'vitest' +import { LifeCycleEventsMap, SetupApi } from 'msw' + +export function spyOnLifeCycleEvents(api: SetupApi) { + const listener = vi.fn() + const requestIdPromise = new DeferredPromise() + + afterEach(() => listener.mockReset()) + + api.events + .on('request:start', ({ request, requestId }) => { + if (request.headers.has('upgrade')) { + return + } + + requestIdPromise.resolve(requestId) + listener(`[request:start] ${request.method} ${request.url} ${requestId}`) + }) + .on('request:match', ({ request, requestId }) => { + listener(`[request:match] ${request.method} ${request.url} ${requestId}`) + }) + .on('request:unhandled', ({ request, requestId }) => { + listener( + `[request:unhandled] ${request.method} ${request.url} ${requestId}`, + ) + }) + .on('request:end', ({ request, requestId }) => { + if (request.headers.has('upgrade')) { + return + } + + listener(`[request:end] ${request.method} ${request.url} ${requestId}`) + }) + .on('response:mocked', async ({ response, request, requestId }) => { + listener( + `[response:mocked] ${request.method} ${request.url} ${requestId} ${ + response.status + } ${await response.clone().text()}`, + ) + }) + .on('response:bypass', async ({ response, request, requestId }) => { + if (request.headers.has('upgrade')) { + return + } + + listener( + `[response:bypass] ${request.method} ${request.url} ${requestId} ${ + response.status + } ${await response.clone().text()}`, + ) + }) + + return { + listener, + requestIdPromise, + } +} diff --git a/test/support/utils.ts b/test/support/utils.ts index 4f631062b..19d826a48 100644 --- a/test/support/utils.ts +++ b/test/support/utils.ts @@ -1,5 +1,5 @@ -import * as path from 'path' -import { ClientRequest, IncomingMessage } from 'http' +import * as path from 'node:path' +import { ClientRequest, IncomingMessage } from 'node:http' export function sleep(duration: number) { return new Promise((resolve) => { diff --git a/tsconfig.json b/tsconfig.json index 57a15aab2..5b14ecd70 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,17 +1,9 @@ { "extends": "./tsconfig.base.json", "references": [ - // Source. - { - "path": "./src/browser/tsconfig.browser.json" - }, - { - "path": "./src/tsconfig.node.json" - }, - - // Tests. - { - "path": "./tsconfig.test.unit.json" - } + { "path": "./src/tsconfig.core.json" }, + { "path": "./src/browser/tsconfig.browser.json" }, + { "path": "./src/tsconfig.node.json" }, + { "path": "./tsconfig.test.unit.json" } ] } diff --git a/tsconfig.test.unit.json b/tsconfig.test.unit.json index abea30c95..ba0d5c8fa 100644 --- a/tsconfig.test.unit.json +++ b/tsconfig.test.unit.json @@ -9,9 +9,5 @@ "types": ["vitest/globals"] }, "include": ["./src/**/*.test.ts", "./test/support"], - "references": [ - { - "path": "./src/tsconfig.src.json" - } - ] + "references": [{ "path": "./src/tsconfig.node.json" }] }