diff --git a/_http_common.d.ts b/_http_common.d.ts index b3db30f77..cb9450d37 100644 --- a/_http_common.d.ts +++ b/_http_common.d.ts @@ -18,26 +18,36 @@ declare var HTTPParser: { export interface HTTPParser { new (): HTTPParser - [HTTPParser.kOnMessageBegin]: () => void - [HTTPParser.kOnHeaders]: HeadersCallback - [HTTPParser.kOnHeadersComplete]: ParserType extends 0 - ? RequestHeadersCompleteCallback - : ResponseHeadersCompleteCallback - [HTTPParser.kOnBody]: (chunk: Buffer) => void - [HTTPParser.kOnMessageComplete]: () => void - [HTTPParser.kOnExecute]: () => void - [HTTPParser.kOnTimeout]: () => void + [HTTPParser.kOnMessageBegin]?: (() => void) | null + [HTTPParser.kOnHeaders]?: HeadersCallback + [HTTPParser.kOnHeadersComplete]?: ParserType extends 0 + ? RequestHeadersCompleteCallback | null + : ResponseHeadersCompleteCallback | null + [HTTPParser.kOnBody]?: ((chunk: Buffer) => void) | null + [HTTPParser.kOnMessageComplete]?: (() => void) | null + [HTTPParser.kOnExecute]?: (() => void) | null + [HTTPParser.kOnTimeout]?: (() => void) | null + + _consumed?: boolean + _headers?: Array + _url: string + maxHeaderPairs: number + socket?: typeof import('node:net').Socket | null + incoming?: typeof import('node:http').IncomingMessage | null + outgoing?: typeof import('node:http').OutgoingMessage | null + onIncoming?: (() => void) | null + joinDuplicateHeaders?: unknown initialize(type: ParserType, asyncResource: object): void execute(buffer: Buffer): void finish(): void - free(): void + unconsume(): void + remove(): void + close(): void + free(): boolean } -export type HeadersCallback = ( - rawHeaders: Array, - url: string -) => void +export type HeadersCallback = (rawHeaders: Array, url: string) => void export type RequestHeadersCompleteCallback = ( versionMajor: number, diff --git a/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts b/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts index 6a4f33adb..a6cfeee1c 100644 --- a/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts +++ b/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts @@ -3,7 +3,7 @@ export function baseUrlFromConnectionOptions(options: any): URL { return new URL(options.href) } - const protocol = options.port === 443 ? 'https:' : 'http:' + const protocol = getProtocolByOptions(options) const host = options.host const url = new URL(`${protocol}//${host}`) @@ -24,3 +24,15 @@ export function baseUrlFromConnectionOptions(options: any): URL { return url } + +function getProtocolByOptions(options: any): string { + if (options.protocol) { + return options.protocol + } + + if (options.secure) { + return 'https:' + } + + return options.port === 443 ? 'https:' : 'http:' +} diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts new file mode 100644 index 000000000..55c7919ce --- /dev/null +++ b/src/interceptors/http/http-parser.ts @@ -0,0 +1,243 @@ +import { + HTTPParser, + type HeadersCallback, + type RequestHeadersCompleteCallback, + type ResponseHeadersCompleteCallback, +} from '_http_common' +import net from 'node:net' +import { Readable } from 'node:stream' +import { invariant } from 'outvariant' +import { FetchResponse } from '../../utils/fetchUtils' +import type { NetworkConnectionOptions } from '../net/utils/normalize-net-connect-args' + +type HttpParserKind = typeof HTTPParser.REQUEST | typeof HTTPParser.RESPONSE + +interface ParserHooks { + onMessageBegin?: () => void + onHeaders?: HeadersCallback + onHeadersComplete?: ParserKind extends typeof HTTPParser.REQUEST + ? RequestHeadersCompleteCallback + : ResponseHeadersCompleteCallback + onBody?: (chunk: Buffer) => void + onMessageComplete?: () => void + onExecute?: () => void + onTimeout?: () => void +} + +export class HttpParser { + static REQUEST = HTTPParser.REQUEST + static RESPONSE = HTTPParser.RESPONSE + + #parser: HTTPParser + + constructor(kind: ParserKind, hooks: ParserHooks) { + this.#parser = new HTTPParser() + this.#parser.initialize(kind, {}) + this.#parser[HTTPParser.kOnMessageBegin] = hooks.onMessageBegin + this.#parser[HTTPParser.kOnHeaders] = hooks.onHeaders + this.#parser[HTTPParser.kOnHeadersComplete] = hooks.onHeadersComplete + this.#parser[HTTPParser.kOnBody] = hooks.onBody + this.#parser[HTTPParser.kOnMessageComplete] = hooks.onMessageComplete + this.#parser[HTTPParser.kOnExecute] = hooks.onExecute + this.#parser[HTTPParser.kOnTimeout] = hooks.onTimeout + } + + public execute(buffer: Buffer): void { + this.#parser.execute(buffer) + } + + /** + * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/_http_common.js#L180 + */ + public free(socket?: net.Socket): void { + if (this.#parser._consumed) { + this.#parser.unconsume() + } + + this.#parser._headers = [] + this.#parser._url = '' + this.#parser.socket = null + this.#parser.incoming = null + this.#parser.outgoing = null + this.#parser.maxHeaderPairs = 2000 + this.#parser[HTTPParser.kOnMessageBegin] = null + this.#parser[HTTPParser.kOnExecute] = null + this.#parser[HTTPParser.kOnTimeout] = null + this.#parser._consumed = false + this.#parser.onIncoming = null + this.#parser.joinDuplicateHeaders = null + + this.#parser.remove() + this.#parser.free() + + if (socket) { + // @ts-expect-error Node.js internals. + socket.parser = null + } + } +} + +export class HttpRequestParser extends HttpParser { + #requestRawHeadersBuffer: Array + #requestBodyStream?: Readable | null + + constructor(options: { + requestOptions: NetworkConnectionOptions & { + method: string + baseUrl: URL + } + onRequest: (request: Request) => void + }) { + super(HttpParser.REQUEST, { + onHeaders: (rawHeaders) => { + this.#requestRawHeadersBuffer.push(...rawHeaders) + }, + onHeadersComplete: ( + versionMajor, + versionMinor, + rawHeaders, + _, + path, + __, + ___, + ____, + shouldKeepAlive + ) => { + const method = options.requestOptions.method?.toUpperCase() || 'GET' + const url = new URL(path || '', options.requestOptions.baseUrl) + + const headers = FetchResponse.parseRawHeaders([ + ...this.#requestRawHeadersBuffer, + ...(rawHeaders || []), + ]) + + const canHaveBody = method !== 'GET' && method !== 'HEAD' + + // Translate the basic authorization to request headers. + // Constructing a Request instance with a URL containing auth is no-op. + if (url.username || url.password) { + if (!headers.has('authorization')) { + headers.set( + 'authorization', + `Basic ${url.username}:${url.password}` + ) + } + url.username = '' + url.password = '' + } + + this.#requestBodyStream = new Readable({ + /** + * @note Provide the `read()` method so a `Readable` could be + * used as the actual request body (the stream calls "read()"). + */ + read() {}, + }) + + const request = new Request(url, { + method, + headers, + credentials: 'same-origin', + // @ts-expect-error Undocumented Fetch property. + duplex: canHaveBody ? 'half' : undefined, + body: canHaveBody + ? (Readable.toWeb(this.#requestBodyStream) as any) + : null, + }) + + /** + * @note Here we used to skip the request handling altogether + * if the "INTERNAL_REQUEST_ID_HEADER_NAME" request header is present. + * That prevented the nested interceptors (XHR -> ClientRequest) from + * conflicting. Do we still need this? + * + * @todo Forgo the old deduplication algo because it's intrusive. + * @see https://github.com/mswjs/interceptors/issues/378 + */ + + options.onRequest(request) + }, + onBody: (chunk) => { + invariant( + this.#requestBodyStream, + 'Failed to write to a request stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub.' + ) + + this.#requestBodyStream.push(chunk) + }, + onMessageComplete: () => { + this.#requestBodyStream?.push(null) + }, + }) + + this.#requestRawHeadersBuffer = [] + } + + public free(socket?: net.Socket): void { + super.free(socket) + this.#requestRawHeadersBuffer = [] + this.#requestBodyStream = null + } +} + +export class HttpResponseParser extends HttpParser { + #responseRawHeadersBuffer: Array + #responseBodyStream?: Readable | null + + constructor(options: { onResponse: (response: Response) => void }) { + super(HttpParser.RESPONSE, { + onHeaders: (rawHeaders) => { + this.#responseRawHeadersBuffer.push(...rawHeaders) + }, + onHeadersComplete: ( + versionMajor, + versionMinor, + rawHeaders, + method, + url, + status, + statusText + ) => { + const headers = FetchResponse.parseRawHeaders([ + ...this.#responseRawHeadersBuffer, + ...(rawHeaders || []), + ]) + + const response = new FetchResponse( + FetchResponse.isResponseWithBody(status) + ? (Readable.toWeb( + (this.#responseBodyStream = new Readable({ read() {} })) + ) as any) + : null, + { + url, + status, + statusText, + headers, + } + ) + + options.onResponse(response) + }, + onBody: (chunk) => { + invariant( + this.#responseBodyStream, + 'Failed to read from a response stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub.' + ) + + this.#responseBodyStream.push(chunk) + }, + onMessageComplete: () => { + this.#responseBodyStream?.push(null) + }, + }) + + this.#responseRawHeadersBuffer = [] + } + + public free(socket?: net.Socket): void { + super.free(socket) + this.#responseRawHeadersBuffer = [] + this.#responseBodyStream = null + } +} diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts new file mode 100644 index 000000000..b0e49c91a --- /dev/null +++ b/src/interceptors/http/index.ts @@ -0,0 +1,357 @@ +import net from 'node:net' +import { Writable } from 'node:stream' +import { invariant } from 'outvariant' +import { Interceptor } from '../../Interceptor' +import { type HttpRequestEventMap } from '../../glossary' +import { SocketInterceptor } from '../net' +import { HttpRequestParser, HttpResponseParser } from './http-parser' +import type { NetworkConnectionOptions } from '../net/utils/normalize-net-connect-args' +import { toBuffer } from './utils/to-buffer' +import { createRequestId } from '../../createRequestId' +import { RequestController } from '../../RequestController' +import { + getRawFetchHeaders, + recordRawFetchHeaders, + restoreHeadersPrototype, +} from '../ClientRequest/utils/recordRawHeaders' +import { isResponseError } from '../../utils/responseUtils' +import { emitAsync } from '../../utils/emitAsync' +import { connectOptionsToUrl } from '../net/utils/connect-options-to-url' + +/** + * @fixme Can we use the socket interceptor as a singleton? + * Also, interceptors are deduped based on the global symbol. + * Will that merge all the clients listening to their events? + */ +const socketInterceptor = new SocketInterceptor() + +export class HttpRequestInterceptor extends Interceptor { + static symbol = Symbol('HttpRequestInterceptor') + + constructor() { + super(HttpRequestInterceptor.symbol) + } + + public setup() { + socketInterceptor.apply() + this.subscriptions.push(() => socketInterceptor.dispose()) + + recordRawFetchHeaders() + this.subscriptions.push(() => restoreHeadersPrototype()) + + socketInterceptor.on( + 'connection', + ({ options, socket, controller: socketController }) => { + socketController.once('write', (chunk, encoding) => { + if (!chunk) { + return + } + + const firstFrame = chunk.toString() + if (!firstFrame.includes('HTTP/')) { + return + } + + // Get the request method from the first frame because it's faster + // and we need this before initiating the HTTP parser. + const method = firstFrame.split(' ')[0] + + invariant( + method != null, + 'Failed to handle HTTP request: expected a valid HTTP method but got %s', + method, + options + ) + + const baseUrl = connectOptionsToUrl(options) + + const requestParser = new HttpRequestParser({ + requestOptions: { + method, + baseUrl, + ...options, + }, + onRequest: async (request) => { + const requestId = createRequestId() + + const requestController = new RequestController(request, { + respondWith: async (response) => { + if (this.emitter.listenerCount('response') > 0) { + const responseClone = response.clone() + process.nextTick(() => { + emitAsync(this.emitter, 'response', { + requestId, + request, + response: responseClone, + isMockedResponse: true, + }) + }) + } + + await this.respondWith({ + socket, + connectionOptions: options, + request, + response, + }) + }, + errorWith: (reason) => { + if (reason instanceof Error) { + socket.destroy(reason) + } + }, + passthrough: () => { + const passthroughSocket = socketController.passthrough() + + /** + * @note Creating a passthrough socket does NOT trigger the "onSocket" callback + * of `ClientRequest` because that callback is invoked manually in the request's constructor. + * Promote the parser-request-parser association manually from the mocked onto the passthrough socket. + * @see https://github.com/nodejs/node/blob/134625d76139b4b3630d5baaf2efccae01ede564/lib/_http_client.js#L422 + * @see https://github.com/nodejs/node/blob/134625d76139b4b3630d5baaf2efccae01ede564/lib/_http_client.js#L890 + */ + // @ts-expect-error Node.js internals. + passthroughSocket._httpMessage = socket._httpMessage + // @ts-expect-error Node.js internals. + passthroughSocket.parser = socket.parser + // @ts-expect-error Node.js internals. + passthroughSocket.parser.socket = passthroughSocket + + // If the user didn't register any response listeners, no need to pay the + // price of routing the entire response message through the parser. + if (this.emitter.listenerCount('response') > 0) { + const responseParser = new HttpResponseParser({ + onResponse: async (response) => { + await emitAsync(this.emitter, 'response', { + requestId, + request, + response, + isMockedResponse: false, + }) + }, + }) + + passthroughSocket + .on('data', (chunk) => responseParser.execute(chunk)) + .on('close', () => responseParser.free()) + } + }, + }) + }, + }) + + // Write the message header to the parser manually because it's already been written + // on the socket so it won't get piped. + requestParser.execute(toBuffer(chunk, encoding)) + + /** + * @note Listen to the internal "write" event and call the parser manually. + * Do NOT pipe the `socket` stream to the parser stream. By piping the stream, + * it gets subjected to being paused and will get stuck when Node.js pauses it + * to wait for the HTTP message while the parser cannot write that message + * because the stream is paused! + */ + socketController.on('write', (chunk, encoding) => { + if (chunk) { + requestParser.execute(toBuffer(chunk, encoding)) + } + }) + + socketController.runInternally((socket) => { + socket + .on('finish', () => requestParser.free()) + .on('error', () => requestParser.free()) + }) + }) + } + ) + } + + /** + * Mocks a successful socket connection. + */ + public mockConnect( + socket: net.Socket, + connectionOptions: NetworkConnectionOptions + ): void { + const isIPv6 = + net.isIPv6(connectionOptions.host || '') || connectionOptions.family === 6 + + const addressInfo = { + address: isIPv6 ? '::1' : '127.0.0.1', + family: isIPv6 ? 'IPv6' : 'IPv4', + port: connectionOptions.port, + } + + /** + * @fixme We used to update `socket.addressInfo` to return the + * internally constructed `addressInfo`. Still needed? + */ + + socket.emit( + 'lookup', + null, + addressInfo, + addressInfo.family === 'IPv6' ? 6 : 4, + connectionOptions.host + ) + socket.emit('connect') + socket.emit('ready') + + if ('encrypted' in socket && socket.encrypted) { + /** + * @note Since we preserve the TLS wrap, we only have to emit + * the events that the wrap expects. We don't need to emit TLS + * events, like "secureConnect" or "session". Node.js will do that for us. + * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/internal/tls/wrap.js#L1667 + */ + /** + * @fixme `socket` here is the TLSSocket! + * It expects "connect" to be emitted by the underlying socket (tlsSocket._parent). + * Since it never does and we write the response and close the stream, + * TLS wrap errors. + * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/internal/tls/wrap.js#L1776 + */ + // socket.emit('secure') <-- THIS IS THE WRONG SOCKET TO EMIT THIS ON. + } + } + + /** + * Pushes the given Fetch API `Response` onto the given socket. + * Automatically establishes a successful mock socket connection. + */ + public async respondWith(args: { + socket: net.Socket + connectionOptions: NetworkConnectionOptions + request: Request + response: Response + }): Promise { + const { socket, connectionOptions, request, response } = args + + // Ignore mock responses for destroyed sockets (e.g. aborted, timed out). + if (socket.destroyed) { + return + } + + // Establish a mocked socket connection. + // Prior to this point, the socket has been pending. + /** + * @fixme Why connect again? The socket MUST connect immmediately + * because the clients might write into it only AFTER connecting. + * Why connect twice for mock scenario? + */ + // this.mockConnect(socket, connectionOptions) + + // Handle `Response.error()` instances. + if (isResponseError(response)) { + socket.destroy(new TypeError('Network error')) + return + } + + /** + * @note Import the "node:http" module lazily so it doesn't create a stale closure + * at the top of this module. Test runners might cache imports and the test will + * get a cached "node:http" with the unpatched "node:net". + */ + const { ServerResponse, IncomingMessage, STATUS_CODES } = await import( + 'node:http' + ) + + // @ts-expect-error Node.js internals. + const socketParser = socket.parser + + // Construct a regular server response to delegate body parsing to Node.js. + const serverResponse = new ServerResponse(new IncomingMessage(socket)) + serverResponse.assignSocket( + /** + * @note Provide a dummy stream to the server response to translate all its writes + * into pushes to the underlying mocked socket. This is only needed because we + * use `ServerResponse` instead of pushing to mock socket directly (skip parsing). + */ + new Writable({ + write(chunk, encoding, callback) { + socket.push(chunk, encoding) + callback?.() + }, + }) as net.Socket + ) + + /** + * @note A hacky way to preserve the socket-parser association set + * on the original socket. Creating `ServerResponse` rewrites that + * association, resulting in the "socketOnData" callback failing the + * socket identity check: + * @see https://github.com/nodejs/node/blob/a73b575304722a3682fbec3a5fb13b39c5791342/lib/_http_client.js#L612 + * @see https://github.com/nodejs/node/blob/a73b575304722a3682fbec3a5fb13b39c5791342/lib/_http_server.js#L713 + */ + socketParser.socket = socket + + /** + * @note Remove the `Connection` and `Date` response headers + * injected by `ServerResponse` by default. Those are required + * from the server but the interceptor is NOT technically a server. + * It's confusing to add response headers that the developer didn't + * specify themselves. They can always add these if they wish. + * @see https://www.rfc-editor.org/rfc/rfc9110#field.date + * @see https://www.rfc-editor.org/rfc/rfc9110#field.connection + */ + serverResponse.removeHeader('connection') + serverResponse.removeHeader('date') + + // Get the raw response headers to preserve their casing. + // We're recording the headers manually since the Fetch API + // normalizes all headers without a way to get the raw values. + const rawResponseHeaders = getRawFetchHeaders(response.headers) + + // Write the response header, providing the raw headers as-is. + // Using `.setHeader()`/`.appendHeader()` normalizes header names. + serverResponse.writeHead( + response.status, + response.statusText || STATUS_CODES[response.status], + rawResponseHeaders + ) + + if (response.body) { + try { + const reader = response.body.getReader() + + while (true) { + const { done, value } = await reader.read() + + if (done) { + serverResponse.end() + break + } + + serverResponse.write(value) + } + } catch (error) { + console.log('response stream error:', error) + + if (error instanceof Error) { + /** + * Destroy the socket if the response stream errored. + * @see https://github.com/mswjs/interceptors/issues/738 + * + * Response errors destroy the socket gracefully (no error). + * Instead, the "error" event is emitted with a more detailed error. + * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/_http_client.js#L586 + */ + socket.destroy() + } + } + } else { + serverResponse.end() + } + + // Close the connection if it wasn't marked as keep-alive. + if (request.headers.get('connection') !== 'keep-alive') { + socket.emit('readable') + /** + * @fixme We used to push null to the response stream manually here. + * Is that still needed? + */ + socket.push(null) + } + } +} diff --git a/src/interceptors/http/utils/to-buffer.ts b/src/interceptors/http/utils/to-buffer.ts new file mode 100644 index 000000000..80dd04d45 --- /dev/null +++ b/src/interceptors/http/utils/to-buffer.ts @@ -0,0 +1,3 @@ +export function toBuffer(data: any, encoding?: BufferEncoding): Buffer { + return Buffer.isBuffer(data) ? data : Buffer.from(data, encoding) +} diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts new file mode 100644 index 000000000..5f6ea3710 --- /dev/null +++ b/src/interceptors/net/index.ts @@ -0,0 +1,212 @@ +import net from 'node:net' +import tls from 'node:tls' +import { Interceptor } from '../../Interceptor' +import { + MockSocket, + MockTlsSocket, + SocketController, + DuplexStreamProxy, +} from './mock-socket' +import { + normalizeNetConnectArgs, + type NetConnectArgs, + type NetworkConnectionOptions, +} from './utils/normalize-net-connect-args' +import { + normalizeTlsConnectArgs, + TlsConnectArgs, +} from './utils/normalize-tls-connect-args.test' + +export interface SocketConnectionEventMap { + connection: [ + args: { + socket: MockSocket + options: NetworkConnectionOptions + controller: SocketController + } + ] +} + +const kImplementation = Symbol('kImplementation') +const kOriginalValue = Symbol('kOriginalValue') + +function createSwitchableProxy(target: any) { + return new Proxy(target, { + apply(target, thisArg, argArray) { + return Reflect.get(target, kImplementation).apply(thisArg, argArray) + }, + }) +} + +if (Reflect.get(net.connect, kImplementation) == null) { + /** + * Apply a transparent proxy to the "node:net" module on the module's scope. + * This way, this interceptor can function if it gets imported before the surface + * that relies on "node:net", like "node:http" or "undici". + * + * @note You MUST import the interceptor BEFORE the surface relying on "node:net". + */ + const { connect: originalConnect } = net + + Object.defineProperties(net.connect, { + [kOriginalValue]: { + value: originalConnect, + }, + [kImplementation]: { + writable: true, + value() { + return Reflect.get(net.connect, kOriginalValue) + }, + }, + }) + + net.connect = createSwitchableProxy(net.connect) + /** + * `net.createConnection` is an alias for `net.connect`. + * @see https://github.com/nodejs/node/blob/9bcc5a8f01acf9583b45b3bbddf8f043a001bb3c/lib/net.js#L2489 + */ + net.createConnection = net.connect +} + +if (Reflect.get(tls.connect, kImplementation) == null) { + const { connect: originalConnect } = tls + + Object.defineProperties(tls.connect, { + [kOriginalValue]: { + value: originalConnect, + }, + [kImplementation]: { + writable: true, + value() { + return Reflect.get(tls.connect, kOriginalValue) + }, + }, + }) + + tls.connect = createSwitchableProxy(tls.connect) +} + +export class SocketInterceptor extends Interceptor { + static symbol = Symbol('SocketInterceptor') + + constructor() { + super(SocketInterceptor.symbol) + } + + protected setup(): void { + const originalNetConnect = Reflect.get( + net.connect, + kOriginalValue + ) as typeof net.connect + + this.subscriptions.push(() => { + Reflect.set(net.connect, kImplementation, originalNetConnect) + }) + + Reflect.set( + net.connect, + kImplementation, + (...args: [any, any]): net.Socket => { + const [options, connectionCallback] = normalizeNetConnectArgs( + args as NetConnectArgs + ) + + const socket = new MockSocket({ + ...args, + connectionCallback, + }) + + const controller = new SocketController({ + socket: socket, + proxy: new DuplexStreamProxy(socket), + createConnection() { + return originalNetConnect(...args) + }, + }) + + /** + * @note Do NOT call `socket.connect()` here. + * Instead, keep the socket connection pending and delegate the actual + * connect to the user. Calling `.connect()` on a mock socket is handy + * for simulating a successful connection. Calling `.passthrough()` will + * tap into the unpatched `net.connect()`, which will call `socket.connect()`. + */ + + process.nextTick(() => { + this.emitter.emit('connection', { + options, + socket, + controller, + }) + }) + + return controller.socket + } + ) + + const originalTlsConnect = Reflect.get( + tls.connect, + kOriginalValue + ) as typeof tls.connect + + this.subscriptions.push(() => { + Reflect.set(tls.connect, kImplementation, originalTlsConnect) + }) + + Reflect.set( + tls.connect, + kImplementation, + (...args: [any, any]): tls.TLSSocket => { + const [tlsOptions, secureConnectionListener] = normalizeTlsConnectArgs( + args as TlsConnectArgs + ) + + /** + * @fixme Ignore TLS sockets where `options.isServer` is `true`. + * Those are constructed for server responses and we shouldn't touch them. + */ + + /** + * Call the original `tls.connect()` to initialize the wrap + * around the underlying `MockSocket`. No need to manage TLS manually. + * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/internal/tls/wrap.js#L1695 + */ + + const socket = new MockSocket({ + ...tlsOptions, + secure: true, + }) + const tlsSocket = new MockTlsSocket( + socket, + tlsOptions, + secureConnectionListener + ) + + const controller = new SocketController({ + socket: tlsSocket, + // Proxy the TLS socket because: + // - a TLS socket is not guaranteed to have an underlying socket (may use "_handle"). + // - the client calls write/end on the TLS socket, not the underlying socket. + proxy: new DuplexStreamProxy(tlsSocket), + createConnection() { + return originalTlsConnect(...args) + }, + }) + + process.nextTick(() => { + this.emitter.emit('connection', { + /** + * @fixme Can we guarantee these options will have "protocol" + * and such? Dunno, dunno. + */ + options: tlsOptions, + socket: tlsSocket, + controller, + }) + }) + + return controller.socket + } + ) + } +} diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts new file mode 100644 index 000000000..0df9dd149 --- /dev/null +++ b/src/interceptors/net/mock-socket.ts @@ -0,0 +1,245 @@ +import net from 'node:net' +import tls from 'node:tls' +import { Duplex } from 'node:stream' +import { Emitter, type EventMap } from 'strict-event-emitter' +import { + normalizeSocketWriteArgs, + type WriteArgs, +} from '../Socket/utils/normalizeSocketWriteArgs' +import { createSocketRecorder, type SocketRecorder } from './socket-recorder' + +interface MockSocketConstructorOptions extends net.SocketConstructorOpts { + secure?: boolean + connectionCallback?: () => void +} + +/** + * A dummy `net.Socket` instance that allows observing written data packets + * and records all consumer interactions to then replay them on the passthrough socket. + * @note This instance is application protocol-agnostic. + */ +export class MockSocket extends net.Socket { + public connecting: boolean + + constructor(protected readonly options: MockSocketConstructorOptions) { + super(options) + this.connecting = true + + if (options.connectionCallback) { + this.once('connect', () => { + options.connectionCallback?.() + }) + } + + this._final = (callback) => callback(null) + + // this[kRecorder] = createSocketRecorder(this, { + // onEntry: (entry) => { + // if ( + // entry.type === 'apply' && + // ['runInternally', 'passthrough'].includes(entry.metadata.property) + // ) { + // return false + // } + + // // Once the connection has been passthrough, replay any recorded events + // // on the passthrough socket immediately. No need to store them. + // if (this[kPassthroughSocket]) { + // entry.replay(this[kPassthroughSocket]) + // return false + // } + // }, + // resolveGetterValue: (target, property) => { + // // Once the socket has been passthrough, resolve any getters + // // against the passthrough socket, not the mock socket. + // if (this[kPassthroughSocket]) { + // return this[kPassthroughSocket][property as keyof net.Socket] + // } + // }, + // }) + // return this[kRecorder].socket + } + + public mockConnect() { + queueMicrotask(() => { + this.connecting = false + this.emit('connect') + }) + + return this + } +} + +// +// +// + +export class SocketController { + public socket: SocketType + + #subscriptions: Array<() => void> + #recorder: SocketRecorder + #passthroughSocket?: SocketType + + public on: Emitter['on'] + public once: Emitter['once'] + + constructor( + protected readonly options: { + socket: SocketType + proxy: DuplexStreamProxy + createConnection: () => SocketType + } + ) { + this.on = options.proxy.on.bind(options.proxy) + this.once = options.proxy.once.bind(options.proxy) + + this.#subscriptions = [] + this.#recorder = createSocketRecorder(options.socket, { + onEntry(entry) { + if ( + entry.type === 'apply' && + ['runInternally', 'passthrough'].includes(entry.metadata.property) + ) { + return false + } + + /** + * @todo Finish this for passthrough. + */ + throw new Error('Tests fail because of this') + }, + }) + + this.socket = this.#recorder.socket + } + + public runInternally(callback: (socket: SocketType) => void): void { + /** + * @fixme While extremely unlikely, this method can be called + * after `.free()` was called and the recorder must not resume. + * This will resume it. No-op. `this.#recorder.readyState`? + */ + + try { + this.#recorder.pause() + callback(this.socket) + } finally { + this.#recorder.resume() + } + } + + public passthrough(): SocketType { + const socket = this.options.createConnection() + this.#recorder.replay(socket) + this.#passthroughSocket = socket + return socket + } + + public free(): void { + this.#recorder.pause() + this.#recorder.free() + + let disposeCallback: (() => void) | undefined + while ((disposeCallback = this.#subscriptions.shift())) { + disposeCallback?.() + } + } +} + +interface SocketProxyEvents extends EventMap { + write: [ + chunk: string | Buffer | null, + encoding?: BufferEncoding, + callback?: () => void + ] +} + +/** + * Adds an in-place proxy over the given streams's `.write()` and `.end()`. + */ +export class DuplexStreamProxy extends Emitter { + public dispose: () => void + + constructor(socket: Duplex) { + super() + + const originalWrite = socket.write.bind(socket) + const originalEnd = socket.end.bind(socket) + + socket.write = (...args: Array) => { + if (this.listenerCount('write') > 0) { + const [chunk, encoding, callback] = normalizeSocketWriteArgs( + args as WriteArgs + ) + this.emit('write', chunk, encoding, callback) + } + return Reflect.apply(originalWrite, socket, args) + } + + socket.end = (...args: Array) => { + if (this.listenerCount('write') > 0) { + const [chunk, encoding, callback] = normalizeSocketWriteArgs( + args as WriteArgs + ) + this.emit('write', chunk, encoding, callback) + } + return Reflect.apply(originalEnd, socket, args) + } + + this.dispose = () => { + socket.write = originalWrite + socket.end = originalEnd + } + } +} + +export class MockTlsSocket extends tls.TLSSocket { + public connecting: boolean + + constructor( + underlyingSocket: MockSocket, + protected readonly options: tls.ConnectionOptions, + secureConnectionListener?: () => void + ) { + super(underlyingSocket, options) + this.connecting = true + + this.once('connect', () => { + process.nextTick(() => { + // Complete the handshake so the socket finishes the connection + // (e.g. emits the "secure" and "session" events). + this.mockHandshake() + }) + }) + + this.once('secure', this.mockSecureConnect.bind(this)) + + if (secureConnectionListener) { + this.once('secureConnect', secureConnectionListener) + } + + process.nextTick(() => { + underlyingSocket.mockConnect() + }) + } + + private mockHandshake(): void { + /** + * Triggers the `_finishInit()` hook and emits "secure". + * @see https://github.com/nodejs/node/blob/a73b575304722a3682fbec3a5fb13b39c5791342/lib/internal/tls/wrap.js#L1050 + */ + // @ts-expect-error Node.js internals. + this._handle.onhandshakedone?.() + } + + private mockSecureConnect(): void { + process.nextTick(() => { + this.emit('secureConnect') + + if (this.options.session) { + this.emit('session', this.options.session) + } + }) + } +} diff --git a/src/interceptors/net/socket-recorder.test.ts b/src/interceptors/net/socket-recorder.test.ts new file mode 100644 index 000000000..635bbed67 --- /dev/null +++ b/src/interceptors/net/socket-recorder.test.ts @@ -0,0 +1,195 @@ +// @vitest-environment node +import net from 'node:net' +import { vi, describe, it, expect } from 'vitest' +import { + createSocketRecorder, + inspectSocketRecorder, + SocketRecorderEntry, +} from './socket-recorder' + +describe('set', () => { + it('ignores unknown property setters', () => { + const { socket } = createSocketRecorder(new net.Socket()) + Object.defineProperty(socket, 'foo', { value: 'abc' }) + + expect( + inspectSocketRecorder(socket), + 'Must not record unknown property setters' + ).not.toEqual( + expect.arrayContaining([ + { + type: 'set', + metadata: expect.objectContaining({ property: 'foo' }), + replay: expect.any(Function), + }, + ]) + ) + }) + + it('ignores symbol setters', () => { + const { socket } = createSocketRecorder(new net.Socket()) + // Calling `.setTimeout()` updates the value of the internal `[kTimeout]` symbol. + socket.setTimeout(1000) + + expect( + inspectSocketRecorder(socket), + 'Must not record symbol setters' + ).not.toEqual( + expect.arrayContaining([ + { + type: 'set', + metadata: expect.objectContaining({ + property: expect.any(Symbol), + }), + replay: expect.any(Function), + }, + ]) + ) + }) + + it('records whitelisted internal setters', () => { + const { socket } = createSocketRecorder(new net.Socket()) + /** + * @note Node.js might set certain internal properties outside + * of any other method calls. Those setters must be preserved + * in order to make the passthrough socket behave as expected. + */ + // @ts-expect-error Node.js internals. + socket._hadError = true + + expect( + inspectSocketRecorder(socket), + 'Must not record implied internal setter' + ).toEqual([ + { + type: 'set', + metadata: { property: '_hadError', newValue: true }, + replay: expect.any(Function), + }, + ]) + }) + + it('ignores internal setters', () => { + const { socket } = createSocketRecorder(new net.Socket()) + socket.on('error', () => {}) + + expect( + inspectSocketRecorder(socket), + 'Must not record implied internal setter' + ).toEqual([ + { + type: 'apply', + metadata: { property: 'on' }, + replay: expect.any(Function), + }, + ]) + }) +}) + +describe('apply', () => { + it('records a single method call', () => { + const { socket } = createSocketRecorder(new net.Socket()) + socket.setTimeout(1000) + + expect(inspectSocketRecorder(socket)).toEqual([ + { + type: 'apply', + metadata: { property: 'setTimeout' }, + replay: expect.any(Function), + }, + ]) + }) + + it('records multiple method calls', () => { + const { socket } = createSocketRecorder(new net.Socket()) + socket.setTimeout(1000) + socket.setKeepAlive(true) + socket.setEncoding('base64') + + expect(inspectSocketRecorder(socket)).toEqual([ + { + type: 'apply', + metadata: { property: 'setTimeout' }, + replay: expect.any(Function), + }, + { + type: 'apply', + metadata: { property: 'setKeepAlive' }, + replay: expect.any(Function), + }, + { + type: 'apply', + metadata: { property: 'setEncoding' }, + replay: expect.any(Function), + }, + ]) + }) + + it('ignores internal method calls', () => { + const { socket } = createSocketRecorder(new net.Socket()) + // Calling `.write()` triggers the internal `._write()`. + socket.write('hello') + socket.on('error', () => void 0) + + expect( + inspectSocketRecorder(socket), + 'Must not record internal method calls' + ).not.toEqual( + expect.arrayContaining([ + { + type: 'apply', + metadata: { property: '_write' }, + replay: expect.any(Function), + }, + ]) + ) + }) + + it('ignores "once" because it is implemented by "on"', () => { + const { socket } = createSocketRecorder(new net.Socket()) + socket.once('connect', () => void 0) + + expect( + inspectSocketRecorder(socket), + 'Must ignore the ".once()" method call' + ).toEqual([ + { + type: 'apply', + metadata: { property: 'on' }, + replay: expect.any(Function), + }, + ]) + }) +}) + +describe('replay', () => { + it('replays method recordings', () => { + const { socket, replay } = createSocketRecorder(new net.Socket()) + socket.setTimeout(123) + + const target = new net.Socket() + replay(target) + + expect(target.timeout, 'Must replay the recorded method call').toBe(123) + expect( + inspectSocketRecorder(socket), + 'Must exhaust the records array' + ).toEqual([]) + }) + + it('replays attached listeners', () => { + const { socket, replay } = createSocketRecorder(new net.Socket()) + const connectListener = vi.fn() + socket.on('connect', connectListener) + + const target = new net.Socket() + replay(target) + target.emit('connect') + + expect( + target.listeners('connect'), + 'Must replay attached listener' + ).toEqual([connectListener]) + expect(connectListener).toHaveBeenCalledOnce() + }) +}) diff --git a/src/interceptors/net/socket-recorder.ts b/src/interceptors/net/socket-recorder.ts new file mode 100644 index 000000000..15cea6a9c --- /dev/null +++ b/src/interceptors/net/socket-recorder.ts @@ -0,0 +1,169 @@ +import net from 'node:net' + +const kSocketRecorder = Symbol('kSocketRecorder') + +export interface SocketRecorder { + socket: T + replay: (newSocket: net.Socket) => void + pause: () => void + resume: () => void + free: () => void +} + +export interface SocketRecorderEntry { + type: 'get' | 'set' | 'apply' + metadata: Record + replay: (newSocket: net.Socket) => void +} + +/** + * Allow certain internal setters to be recorded and replayed + * because they aren't set in response to any action. + * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/_http_client.js#L597 + */ +const INTERNAL_SETTER_WHITELIST = ['_hadError'] + +function isInternalSetter(property: string): boolean { + if (INTERNAL_SETTER_WHITELIST.includes(property)) { + return false + } + + return property.startsWith('_') +} + +/** + * Creates a proxy over the given mock `Socket` instance + * that records all the property setters and methods calls + * so they can later be replayed on the passthrough socket. + */ +export function createSocketRecorder( + socket: T, + options?: { + onEntry?: (entry: SocketRecorderEntry) => boolean | void + resolveGetterValue?: ( + target: any, + property: string | symbol, + receiver: any + ) => void + } +): SocketRecorder { + let isPaused = false + const entries: Array = [] + + Object.defineProperty(socket, kSocketRecorder, { + value: entries, + configurable: true, + enumerable: false, + }) + + const addEntry = (entry: SocketRecorderEntry) => { + if (isPaused) { + return + } + + if (options?.onEntry?.(entry) !== false) { + entries.push(entry) + } + } + + const proxy = new Proxy(socket, { + get(target, property, receiver) { + if ( + typeof property === 'string' && + !property.startsWith('_') && + typeof target[property as keyof T] === 'function' + ) { + return new Proxy(target[property as keyof T] as Function, { + apply(fn, thisArg, args) { + const defaultApply = () => { + return fn.apply(thisArg, args) + } + + if (fn.name === 'destroy') { + entries.length = 0 + return defaultApply() + } + + /** + * @note Ignore recording certain method calls. + * - push, because pushing to the mock socket must never be replayed; + * - once, because it's implemented by "on", resulting in both being recorded + * and their listeners firing twice. + */ + if (fn.name !== 'push' && fn.name !== 'once') { + addEntry({ + type: 'apply', + metadata: { + property, + }, + replay(newSocket) { + Reflect.apply( + newSocket[property as keyof net.Socket] as Function, + newSocket, + args + ) + }, + }) + } + + return defaultApply() + }, + }) + } + + return ( + options?.resolveGetterValue?.(target, property, receiver) ?? + Reflect.get(target, property, receiver) + ) + }, + set(target, property, newValue, receiver) { + const defaultSetter = () => { + return Reflect.set(target, property, newValue, receiver) + } + + if (typeof property === 'symbol' || isInternalSetter(property)) { + return defaultSetter() + } + + const attributes = Object.getOwnPropertyDescriptor(target, property) + if (attributes == null || !attributes.writable) { + return defaultSetter() + } + + addEntry({ + type: 'set', + metadata: { property, newValue }, + replay(newSocket) { + Reflect.set(newSocket, property, newValue, newSocket) + }, + }) + + return defaultSetter() + }, + }) + + return { + socket: proxy, + replay(newSocket) { + for (const entry of entries) { + entry.replay(newSocket) + } + entries.length = 0 + }, + free() { + entries.length = 0 + }, + pause() { + isPaused = true + }, + resume() { + isPaused = false + }, + } +} + +export function inspectSocketRecorder( + socket: net.Socket +): SocketRecorder | undefined { + return Reflect.get(socket, kSocketRecorder) as SocketRecorder +} diff --git a/src/interceptors/net/utils/connect-options-to-url.ts b/src/interceptors/net/utils/connect-options-to-url.ts new file mode 100644 index 000000000..e73dd43e1 --- /dev/null +++ b/src/interceptors/net/utils/connect-options-to-url.ts @@ -0,0 +1,49 @@ +import net from 'node:net' +import { NetworkConnectionOptions } from './normalize-net-connect-args' + +/** + * Creates a `URL` instance out of the `net.connect()` options. + * @note This implies that the passed connection is an HTTP connection. + */ +export function connectOptionsToUrl(options: NetworkConnectionOptions): URL { + const isIPv6 = options.family === 6 || net.isIPv6(options.host || '') + const protocol = getProtocolByConnectionOptions(options) + const host = options.host || 'localhost' + + const url = new URL(`${protocol}//${isIPv6 ? `[${host}]` : host}`) + + if (options.path) { + url.pathname = options.path + } + + if (options.port) { + url.port = options.port.toString() + } + + if (options.auth) { + const [username, password] = options.auth.split(':') + /** + * Authentication options are provided as plain values. + * Encode them to form a valid URL. + * @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/internal/url.js#L1452 + */ + url.username = encodeURIComponent(username) + url.password = encodeURIComponent(password) + } + + return url +} + +function getProtocolByConnectionOptions( + options: NetworkConnectionOptions +): string { + if (options.protocol) { + return options.protocol + } + + if (options.secure) { + return 'https:' + } + + return options.port === 443 ? 'https:' : 'http:' +} diff --git a/src/interceptors/net/utils/normalize-net-connect-args.test.ts b/src/interceptors/net/utils/normalize-net-connect-args.test.ts new file mode 100644 index 000000000..88ccb5cef --- /dev/null +++ b/src/interceptors/net/utils/normalize-net-connect-args.test.ts @@ -0,0 +1,153 @@ +import net from 'node:net' +import { + normalizeNetConnectArgs, + type NormalizedNetConnectArgs, +} from './normalize-net-connect-args' + +it('handles a single path as the argument', () => { + expect( + normalizeNetConnectArgs(['/resource']) + ).toEqual([ + { + path: '/resource', + }, + undefined, + ]) + + const callback = () => void 0 + expect( + normalizeNetConnectArgs(['/resource', callback]) + ).toEqual([ + { + path: '/resource', + }, + callback, + ]) +}) + +it('handles a port and host as the arguments', () => { + expect( + normalizeNetConnectArgs([443, '127.0.0.1']) + ).toEqual([ + { + port: 443, + host: '127.0.0.1', + path: '', + }, + undefined, + ]) + + const callback = () => void 0 + expect( + normalizeNetConnectArgs([443, '127.0.0.1', callback]) + ).toEqual([ + { + port: 443, + host: '127.0.0.1', + path: '', + }, + callback, + ]) +}) + +it('handles a URL as the argument', () => { + expect( + normalizeNetConnectArgs([new URL('https://localhost:3000')]) + ).toEqual([ + { + protocol: 'https:', + port: 3000, + host: 'localhost', + path: '/', + }, + undefined, + ]) + + { + const callback = () => void 0 + expect( + normalizeNetConnectArgs([new URL('https://localhost:3000'), callback]) + ).toEqual([ + { + protocol: 'https:', + port: 3000, + host: 'localhost', + path: '/', + }, + callback, + ]) + } + + expect( + normalizeNetConnectArgs([new URL('https://localhost:3000/resource')]) + ).toEqual([ + { + protocol: 'https:', + port: 3000, + host: 'localhost', + path: '/resource', + }, + undefined, + ]) + + { + const callback = () => void 0 + expect( + normalizeNetConnectArgs([ + new URL('https://localhost:3000/resource'), + callback, + ]) + ).toEqual([ + { + protocol: 'https:', + port: 3000, + host: 'localhost', + path: '/resource', + }, + callback, + ]) + } +}) + +it('handles connection options object as the argument', () => { + expect( + normalizeNetConnectArgs([ + { path: '/resource' } satisfies net.IpcNetConnectOpts, + ]) + ).toEqual([{ path: '/resource' }, undefined]) + + expect( + normalizeNetConnectArgs([ + { port: 443, host: '127.0.0.1' } satisfies net.TcpNetConnectOpts, + ]) + ).toEqual([ + { + port: 443, + host: '127.0.0.1', + path: '', + }, + undefined, + ]) + + expect( + normalizeNetConnectArgs([ + { + port: 443, + host: '127.0.0.1', + family: 6, + localAddress: '::1', + localPort: 80, + } satisfies net.TcpNetConnectOpts, + ]) + ).toEqual([ + { + port: 443, + host: '127.0.0.1', + path: '', + family: 6, + localAddress: '::1', + localPort: 80, + }, + undefined, + ]) +}) diff --git a/src/interceptors/net/utils/normalize-net-connect-args.ts b/src/interceptors/net/utils/normalize-net-connect-args.ts new file mode 100644 index 000000000..e63d20a98 --- /dev/null +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -0,0 +1,88 @@ +import net from 'node:net' +import url from 'node:url' + +export interface NetworkConnectionOptions { + secure?: boolean | null + port?: number | null + path: string | null + host?: string | null + protocol?: string | null + auth?: string | null + family?: number | null + session?: Buffer + localAddress?: string | null + localPort?: number | null +} + +export type NetConnectArgs = + | [options: net.NetConnectOpts, connectionListener?: () => void] + | [url: URL, connectionListener?: () => void] + | [port: number, host: string, connectionListener?: () => void] + | [path: string, connectionListener?: () => void] + +export type NormalizedNetConnectArgs = [ + options: NetworkConnectionOptions, + connectionListener?: () => void +] + +/** + * Normalizes the arguments passed to `net.connect()`. + */ +export function normalizeNetConnectArgs( + args: NetConnectArgs +): NormalizedNetConnectArgs { + const callback = typeof args[1] === 'function' ? args[1] : args[2] + + if (typeof args[0] === 'string') { + return [{ path: args[0] }, callback] + } + + if (typeof args[0] === 'number' && typeof args[1] === 'string') { + return [{ port: args[0], path: '', host: args[1] }, callback] + } + + if (typeof args[0] === 'object') { + if ('href' in args[0]) { + const options = url.urlToHttpOptions(args[0]) + + return [ + { + protocol: args[0].protocol, + path: options.path || '', + port: +args[0].port, + host: options.hostname, + auth: options.auth, + }, + callback, + ] + } + + if ('port' in args[0]) { + return [ + { + path: '', + port: args[0].port, + host: args[0].host, + auth: Reflect.get(args[0], 'auth'), + family: args[0].family, + session: Reflect.get(args[0], 'session'), + localAddress: args[0].localAddress, + localPort: args[0].localPort, + }, + callback, + ] + } + + return [ + { + path: args[0].path || '', + family: Reflect.get(args[0], 'family'), + session: Reflect.get(args[0], 'session'), + auth: Reflect.get(args[0], 'auth'), + }, + callback, + ] + } + + throw new Error(`Invalid arguments passed to net.connect: ${args}`) +} diff --git a/src/interceptors/net/utils/normalize-tls-connect-args.test.ts b/src/interceptors/net/utils/normalize-tls-connect-args.test.ts new file mode 100644 index 000000000..0f2fc2b32 --- /dev/null +++ b/src/interceptors/net/utils/normalize-tls-connect-args.test.ts @@ -0,0 +1,41 @@ +import tls from 'node:tls' + +export type TlsConnectArgs = + | [options: tls.ConnectionOptions, secureConnectionListener?: () => void] + | [ + port: number, + host?: string, + options?: tls.ConnectionOptions, + secureConnectionListener?: () => void + ] + | [ + port: number, + options?: tls.ConnectionOptions, + secureConnectionListener?: () => void + ] + +export function normalizeTlsConnectArgs( + args: TlsConnectArgs +): [tls.ConnectionOptions, secureConnectionListener?: () => void] { + if (typeof args[0] === 'object') { + const callback = typeof args[1] === 'function' ? args[1] : undefined + return [args[0], callback] + } + + if (typeof args[0] === 'number') { + const options = typeof args[1] === 'object' ? args[1] : {} + const host = typeof args[1] === 'string' ? args[1] : undefined + const callback = typeof args[2] === 'function' ? args[2] : args[3] + + return [ + { + port: args[0], + host, + ...options, + }, + callback, + ] + } + + throw new TypeError('Invalid `tls.connect` arguments') +} diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts index 1f82d2e8e..bcb09707d 100644 --- a/test/modules/http/compliance/events.test.ts +++ b/test/modules/http/compliance/events.test.ts @@ -1,10 +1,7 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index' import { HttpRequestEventMap } from '../../../../src/glossary' import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' @@ -12,15 +9,22 @@ const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { res.send('original-response') }) + app.post('/', (req, res) => { + res.send() + }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() await httpServer.listen() }) +afterEach(() => { + interceptor.removeAllListeners() +}) + afterAll(async () => { interceptor.dispose() await httpServer.close() @@ -51,10 +55,10 @@ it('emits the "request" event for an outgoing request without body', async () => expect(request.body).toBe(null) }) -it('emits the "request" event for an outgoing request with a body', async () => { +it('emits the "request" event for a bypassed request with a body', async () => { const requestListener = vi.fn<(...args: HttpRequestEventMap['request']) => void>() - interceptor.once('request', requestListener) + interceptor.on('request', requestListener) const request = http.request(httpServer.http.url('/'), { method: 'POST', @@ -65,21 +69,22 @@ it('emits the "request" event for an outgoing request with a body', async () => }) request.write('post-payload') request.end() + await waitForClientRequest(request) expect(requestListener).toHaveBeenCalledTimes(1) - const { request: requestFromListener } = requestListener.mock.calls[0][0] - expect(requestFromListener).toBeInstanceOf(Request) - expect(requestFromListener.method).toBe('POST') - expect(requestFromListener.url).toBe(httpServer.http.url('/')) + const { request: interceptedRequest } = requestListener.mock.calls[0][0] + expect(interceptedRequest).toBeInstanceOf(Request) + expect(interceptedRequest.method).toBe('POST') + expect(interceptedRequest.url).toBe(httpServer.http.url('/')) expect( - Object.fromEntries(requestFromListener.headers.entries()) + Object.fromEntries(interceptedRequest.headers.entries()) ).toMatchObject({ 'content-type': 'text/plain', 'x-custom-header': 'yes', }) - expect(await requestFromListener.text()).toBe('post-payload') + await expect(interceptedRequest.text()).resolves.toBe('post-payload') }) it('emits the "response" event for a mocked response', async () => { @@ -102,28 +107,28 @@ it('emits the "response" event for a mocked response', async () => { const { response, requestId, - request: requestFromListener, + request: interceptedRequest, isMockedResponse, } = responseListener.mock.calls[0][0] expect(response).toBeInstanceOf(Response) expect(response.status).toBe(200) - expect(await response.text()).toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') expect(isMockedResponse).toBe(true) expect(requestId).toMatch(REQUEST_ID_REGEXP) - expect(requestFromListener).toBeInstanceOf(Request) - expect(requestFromListener.method).toBe('GET') - expect(requestFromListener.url).toBe('http://localhost/') + expect(interceptedRequest).toBeInstanceOf(Request) + expect(interceptedRequest.method).toBe('GET') + expect(interceptedRequest.url).toBe('http://localhost/') expect( - Object.fromEntries(requestFromListener.headers.entries()) + Object.fromEntries(interceptedRequest.headers.entries()) ).toMatchObject({ 'x-custom-header': 'yes', }) - expect(requestFromListener.body).toBe(null) + expect(interceptedRequest.body).toBe(null) // Must respond with the mocked response. expect(res.statusCode).toBe(200) - expect(await text()).toBe('hello world') + await expect(text()).resolves.toBe('hello world') }) it('emits the "response" event for a bypassed response', async () => { @@ -138,31 +143,35 @@ it('emits the "response" event for a bypassed response', async () => { }) const { res, text } = await waitForClientRequest(request) - // Must emit the "response" interceptor event. - expect(responseListener).toHaveBeenCalledTimes(1) + // Must respond with the mocked response. + expect.soft(res.statusCode).toBe(200) + await expect.soft(text()).resolves.toBe('original-response') + + expect( + responseListener, + 'Must emit the "response" event' + ).toHaveBeenCalledTimes(1) + const { response, requestId, - request: requestFromListener, + request: interceptedRequest, isMockedResponse, } = responseListener.mock.calls[0][0] + expect(response).toBeInstanceOf(Response) expect(response.status).toBe(200) - expect(await response.text()).toBe('original-response') + await expect(response.text()).resolves.toBe('original-response') expect(isMockedResponse).toBe(false) expect(requestId).toMatch(REQUEST_ID_REGEXP) - expect(requestFromListener).toBeInstanceOf(Request) - expect(requestFromListener.method).toBe('GET') - expect(requestFromListener.url).toBe(httpServer.http.url('/')) + expect(interceptedRequest).toBeInstanceOf(Request) + expect(interceptedRequest.method).toBe('GET') + expect(interceptedRequest.url).toBe(httpServer.http.url('/')) expect( - Object.fromEntries(requestFromListener.headers.entries()) + Object.fromEntries(interceptedRequest.headers.entries()) ).toMatchObject({ 'x-custom-header': 'yes', }) - expect(requestFromListener.body).toBe(null) - - // Must respond with the mocked response. - expect(res.statusCode).toBe(200) - expect(await text()).toBe('original-response') + expect(interceptedRequest.body).toBe(null) }) diff --git a/test/modules/http/compliance/http-custom-agent.test.ts b/test/modules/http/compliance/http-custom-agent.test.ts index d902b17a1..124b80ab5 100644 --- a/test/modules/http/compliance/http-custom-agent.test.ts +++ b/test/modules/http/compliance/http-custom-agent.test.ts @@ -1,12 +1,11 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../../test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => res.send('hello world')) diff --git a/test/modules/http/compliance/http-errors.test.ts b/test/modules/http/compliance/http-errors.test.ts index 6a9f70442..cf76b5111 100644 --- a/test/modules/http/compliance/http-errors.test.ts +++ b/test/modules/http/compliance/http-errors.test.ts @@ -1,13 +1,10 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { sleep, waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interface NotFoundError extends NodeJS.ErrnoException { hostname: string diff --git a/test/modules/http/compliance/http-event-connect.test.ts b/test/modules/http/compliance/http-event-connect.test.ts index ca745e1cc..48d06ff3b 100644 --- a/test/modules/http/compliance/http-event-connect.test.ts +++ b/test/modules/http/compliance/http-event-connect.test.ts @@ -1,11 +1,8 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest/index' import { waitForClientRequest } from '../../../../test/helpers' const httpServer = new HttpServer((app) => { @@ -14,7 +11,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() diff --git a/test/modules/http/compliance/http-head-response-body.test.ts b/test/modules/http/compliance/http-head-response-body.test.ts index 4ea5e3e7f..8d714f700 100644 --- a/test/modules/http/compliance/http-head-response-body.test.ts +++ b/test/modules/http/compliance/http-head-response-body.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/compliance/http-max-header-fields-count.test.ts b/test/modules/http/compliance/http-max-header-fields-count.test.ts index fb2bc42b0..3f165fbc4 100644 --- a/test/modules/http/compliance/http-max-header-fields-count.test.ts +++ b/test/modules/http/compliance/http-max-header-fields-count.test.ts @@ -1,11 +1,10 @@ // @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import { afterAll, afterEach, beforeAll, it, expect } from 'vitest' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' import { DeferredPromise } from '@open-draft/deferred-promise' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -100,9 +99,9 @@ it('supports responses with more than default maximum header fields count', asyn interceptor.on('request', ({ controller }) => { const response = new Response(null, { status: 200, - headers: new Headers(responseHeadersPairs) + headers: new Headers(responseHeadersPairs), }) - + controller.respondWith(response) }) diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index cfbbff407..343aba5e0 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -1,10 +1,7 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' const server = new HttpServer((app) => { @@ -13,7 +10,7 @@ const server = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { await server.listen() diff --git a/test/modules/http/compliance/http-rate-limit.test.ts b/test/modules/http/compliance/http-rate-limit.test.ts index e7a7ae63b..4f5b57e91 100644 --- a/test/modules/http/compliance/http-rate-limit.test.ts +++ b/test/modules/http/compliance/http-rate-limit.test.ts @@ -1,9 +1,8 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import rateLimit from 'express-rate-limit' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.use( @@ -27,7 +26,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { const url = new URL(request.url) diff --git a/test/modules/http/compliance/http-req-callback.test.ts b/test/modules/http/compliance/http-req-callback.test.ts index ebf86d255..87bce9e85 100644 --- a/test/modules/http/compliance/http-req-callback.test.ts +++ b/test/modules/http/compliance/http-req-callback.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { IncomingMessage } from 'node:http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/get', (req, res) => { @@ -14,7 +11,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { if ([httpServer.https.url('/get')].includes(request.url)) { return diff --git a/test/modules/http/compliance/http-req-get-with-body.test.ts b/test/modules/http/compliance/http-req-get-with-body.test.ts index 5d3107240..aca983e90 100644 --- a/test/modules/http/compliance/http-req-get-with-body.test.ts +++ b/test/modules/http/compliance/http-req-get-with-body.test.ts @@ -1,13 +1,12 @@ /** * @see https://github.com/nock/nock/issues/2826 */ -import { it, expect, beforeAll, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' import { waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new http.Server((req, res) => { if (req.url === '/resource') { diff --git a/test/modules/http/compliance/http-req-method.test.ts b/test/modules/http/compliance/http-req-method.test.ts index 3e767fae9..419bf7109 100644 --- a/test/modules/http/compliance/http-req-method.test.ts +++ b/test/modules/http/compliance/http-req-method.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/compliance/http-req-url-to-http-options.test.ts b/test/modules/http/compliance/http-req-url-to-http-options.test.ts index 927589d65..f8ca28590 100644 --- a/test/modules/http/compliance/http-req-url-to-http-options.test.ts +++ b/test/modules/http/compliance/http-req-url-to-http-options.test.ts @@ -1,11 +1,10 @@ // @vitest-environment node import { urlToHttpOptions } from 'node:url' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../../test/helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index 8eb55a390..f7e1cc2b6 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { Readable } from 'node:stream' -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import http from 'node:http' import express from 'express' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { sleep, waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { @@ -17,7 +14,7 @@ const httpServer = new HttpServer((app) => { const interceptedRequestBody = vi.fn() -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', async ({ request }) => { interceptedRequestBody(await request.clone().text()) }) diff --git a/test/modules/http/compliance/http-request-ipv6.test.ts b/test/modules/http/compliance/http-request-ipv6.test.ts index d7e0a839a..fad0d8763 100644 --- a/test/modules/http/compliance/http-request-ipv6.test.ts +++ b/test/modules/http/compliance/http-request-ipv6.test.ts @@ -1,9 +1,9 @@ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { httpGet } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { DeferredPromise } from '@open-draft/deferred-promise' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/compliance/http-request-without-options.test.ts b/test/modules/http/compliance/http-request-without-options.test.ts index 0b87faabe..a906534d2 100644 --- a/test/modules/http/compliance/http-request-without-options.test.ts +++ b/test/modules/http/compliance/http-request-without-options.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { if (request.url === 'http://localhost/') { diff --git a/test/modules/http/compliance/http-res-destroy.test.ts b/test/modules/http/compliance/http-res-destroy.test.ts index 12ce630ea..0dc0210a1 100644 --- a/test/modules/http/compliance/http-res-destroy.test.ts +++ b/test/modules/http/compliance/http-res-destroy.test.ts @@ -1,7 +1,6 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { HttpServer } from '@open-draft/test-server/lib/http' import { waitForClientRequest } from '../../../helpers' @@ -9,7 +8,7 @@ const httpServer = new HttpServer((app) => { app.get('/', (req, res) => res.sendStatus(200)) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() diff --git a/test/modules/http/compliance/http-res-non-configurable.test.ts b/test/modules/http/compliance/http-res-non-configurable.test.ts index 70e69891b..a348e3ee6 100644 --- a/test/modules/http/compliance/http-res-non-configurable.test.ts +++ b/test/modules/http/compliance/http-res-non-configurable.test.ts @@ -2,15 +2,14 @@ /** * @see https://github.com/mswjs/msw/issues/2307 */ -import http from 'node:http' -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import http from 'node:http' import { FetchResponse } from '../../../../src/utils/fetchUtils' import { waitForClientRequest } from '../../../helpers' import { DeferredPromise } from '@open-draft/deferred-promise' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.get('/resource', (_req, res) => { diff --git a/test/modules/http/compliance/http-res-raw-headers.test.ts b/test/modules/http/compliance/http-res-raw-headers.test.ts index da5e23240..2c7958de6 100644 --- a/test/modules/http/compliance/http-res-raw-headers.test.ts +++ b/test/modules/http/compliance/http-res-raw-headers.test.ts @@ -1,10 +1,7 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' // The actual server is here for A/B purpose only. @@ -15,7 +12,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() diff --git a/test/modules/http/compliance/http-res-read-multiple-times.test.ts b/test/modules/http/compliance/http-res-read-multiple-times.test.ts index a1df70ef9..d61725193 100644 --- a/test/modules/http/compliance/http-res-read-multiple-times.test.ts +++ b/test/modules/http/compliance/http-res-read-multiple-times.test.ts @@ -4,11 +4,10 @@ * event does not lock that stream for any further reading. * @see https://github.com/mswjs/interceptors/issues/161 */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestEventMap } from '../../../../src' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -18,7 +17,7 @@ const httpServer = new HttpServer((app) => { const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { diff --git a/test/modules/http/compliance/http-res-set-encoding.test.ts b/test/modules/http/compliance/http-res-set-encoding.test.ts index d7e5ac4bc..041aa1c88 100644 --- a/test/modules/http/compliance/http-res-set-encoding.test.ts +++ b/test/modules/http/compliance/http-res-set-encoding.test.ts @@ -1,11 +1,8 @@ -/** - * @vitest-environment node - */ -import { it, expect, describe, beforeAll, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/resource', (request, res) => { @@ -13,7 +10,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { const url = new URL(request.url) diff --git a/test/modules/http/compliance/http-response-headers-folding.test.ts b/test/modules/http/compliance/http-response-headers-folding.test.ts index ac9c1b4c3..d94c51a21 100644 --- a/test/modules/http/compliance/http-response-headers-folding.test.ts +++ b/test/modules/http/compliance/http-response-headers-folding.test.ts @@ -1,10 +1,9 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/compliance/http-signal.test.ts b/test/modules/http/compliance/http-signal.test.ts index 917a1b491..44cf0d2f7 100644 --- a/test/modules/http/compliance/http-signal.test.ts +++ b/test/modules/http/compliance/http-signal.test.ts @@ -1,11 +1,8 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { sleep } from '../../../helpers' const httpServer = new HttpServer((app) => { @@ -15,7 +12,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() diff --git a/test/modules/http/compliance/http-ssl-socket.test.ts b/test/modules/http/compliance/http-ssl-socket.test.ts index f86e702c1..29867725a 100644 --- a/test/modules/http/compliance/http-ssl-socket.test.ts +++ b/test/modules/http/compliance/http-ssl-socket.test.ts @@ -1,13 +1,10 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import https from 'node:https' -import type { TLSSocket } from 'node:tls' +import { TLSSocket } from 'node:tls' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -23,7 +20,7 @@ afterAll(() => { it('emits a correct TLS Socket instance for a handled HTTPS request', async () => { interceptor.on('request', ({ controller }) => { - controller.respondWith(new Response('hello world')) + controller.respondWith(new Response()) }) const request = https.get('https://example.com') @@ -32,16 +29,22 @@ it('emits a correct TLS Socket instance for a handled HTTPS request', async () = const socket = await socketPromise - // Must be a TLS socket. - expect(socket.encrypted).toBe(true) - // The server certificate wasn't signed by one of the CA - // specified in the Socket constructor. - expect(socket.authorized).toBe(false) + expect.soft(socket).toBeInstanceOf(TLSSocket) + // Must be a TLS socket. + expect.soft(socket.encrypted).toBe(true) + expect.soft(socket.authorized, 'Must not have signed certificate').toBe(false) + expect.soft(socket.getSession()).toBeUndefined() + expect.soft(socket.getProtocol()).toBe('TLSv1.3') + expect.soft(socket.isSessionReused()).toBe(false) expect(socket.getSession()).toBeUndefined() expect(socket.getProtocol()).toBe('TLSv1.3') expect(socket.isSessionReused()).toBe(false) - expect(socket.getCipher()).toEqual({ name: 'AES256-SHA', standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', version: 'TLSv1.3' }) + expect(socket.getCipher()).toEqual({ + name: 'AES256-SHA', + standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', + version: 'TLSv1.3', + }) }) it('emits a correct TLS Socket instance for a bypassed HTTPS request', async () => { @@ -51,13 +54,18 @@ it('emits a correct TLS Socket instance for a bypassed HTTPS request', async () const socket = await socketPromise - // Must be a TLS socket. - expect(socket.encrypted).toBe(true) - // The server certificate wasn't signed by one of the CA - // specified in the Socket constructor. - expect(socket.authorized).toBe(false) + expect.soft(socket).toBeInstanceOf(TLSSocket) + expect.soft(socket.encrypted).toBe(true) + expect.soft(socket.authorized, 'Must not have signed certificate').toBe(false) + expect.soft(socket.getSession()).toBeUndefined() + expect.soft(socket.getProtocol()).toBe('TLSv1.3') + expect.soft(socket.isSessionReused()).toBe(false) expect(socket.getSession()).toBeUndefined() expect(socket.getProtocol()).toBe('TLSv1.3') - expect(socket.getCipher()).toEqual({ name: 'AES256-SHA', standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', version: 'TLSv1.3' }) + expect(socket.getCipher()).toEqual({ + name: 'AES256-SHA', + standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA', + version: 'TLSv1.3', + }) }) diff --git a/test/modules/http/compliance/http-timeout.test.ts b/test/modules/http/compliance/http-timeout.test.ts index bcd2428b6..838ab545d 100644 --- a/test/modules/http/compliance/http-timeout.test.ts +++ b/test/modules/http/compliance/http-timeout.test.ts @@ -1,11 +1,8 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { sleep } from '../../../helpers' const httpServer = new HttpServer((app) => { @@ -15,7 +12,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() diff --git a/test/modules/http/compliance/http-unhandled-exception.test.ts b/test/modules/http/compliance/http-unhandled-exception.test.ts index 2adf5a81a..26ce6c193 100644 --- a/test/modules/http/compliance/http-unhandled-exception.test.ts +++ b/test/modules/http/compliance/http-unhandled-exception.test.ts @@ -1,10 +1,10 @@ // @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/compliance/http-unix-socket.test.ts b/test/modules/http/compliance/http-unix-socket.test.ts index 1599fc331..e99f4348e 100644 --- a/test/modules/http/compliance/http-unix-socket.test.ts +++ b/test/modules/http/compliance/http-unix-socket.test.ts @@ -6,7 +6,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from 'vitest' import path from 'node:path' import http from 'node:http' import { promisify } from 'node:util' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { waitForClientRequest } from '../../../helpers' // const HTTP_SOCKET_PATH = mockFs.resolve('./test.sock') @@ -22,7 +22,7 @@ const httpServer = http.createServer((req, res) => { } }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { await new Promise((resolve) => { diff --git a/test/modules/http/compliance/http-upgrade.test.ts b/test/modules/http/compliance/http-upgrade.test.ts index ea286ae36..7b1efd446 100644 --- a/test/modules/http/compliance/http-upgrade.test.ts +++ b/test/modules/http/compliance/http-upgrade.test.ts @@ -2,12 +2,11 @@ * @see https://github.com/mswjs/interceptors/issues/682 */ // @vitest-environment node-with-websocket -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { Server } from 'socket.io' import { io } from 'socket.io-client' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const server = new Server(51678) beforeAll(() => { diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index 5313cbff0..7ff5633f7 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -1,13 +1,12 @@ // @vitest-environment node -import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import express from 'express' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.use(express.json()) diff --git a/test/modules/http/compliance/https-constructor.test.ts b/test/modules/http/compliance/https-constructor.test.ts index 6732e62b8..d83b8b59b 100644 --- a/test/modules/http/compliance/https-constructor.test.ts +++ b/test/modules/http/compliance/https-constructor.test.ts @@ -2,21 +2,20 @@ * @vitest-environment node * @see https://github.com/mswjs/interceptors/issues/131 */ -import { it, expect, beforeAll, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { IncomingMessage } from 'node:http' import https from 'node:https' import { URL } from 'node:url' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' import { getIncomingMessageBody } from '../../../../src/interceptors/ClientRequest/utils/getIncomingMessageBody' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { res.status(200).send('hello') }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { await httpServer.listen() diff --git a/test/modules/http/compliance/https-custom-agent.test.ts b/test/modules/http/compliance/https-custom-agent.test.ts index 7308b6f26..2ddc5f06e 100644 --- a/test/modules/http/compliance/https-custom-agent.test.ts +++ b/test/modules/http/compliance/https-custom-agent.test.ts @@ -1,9 +1,8 @@ // @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import https from 'node:https' -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { @@ -12,7 +11,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() diff --git a/test/modules/http/compliance/https.test.ts b/test/modules/http/compliance/https.test.ts index 2d392c454..54037061c 100644 --- a/test/modules/http/compliance/https.test.ts +++ b/test/modules/http/compliance/https.test.ts @@ -1,10 +1,7 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { @@ -13,7 +10,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() diff --git a/test/modules/http/http-performance.test.ts b/test/modules/http/http-performance.test.ts index a22cff9c4..9c4166f44 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -1,10 +1,7 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' +import { HttpRequestInterceptor } from '../../../src/interceptors/http' import { httpGet, PromisifiedResponse, useCors } from '../../helpers' -import { ClientRequestInterceptor } from '../../../src/interceptors/ClientRequest' function arrayWith(length: number, mapFn: (index: number) => V): V[] { return new Array(length).fill(null).map((_, index) => mapFn(index)) @@ -31,7 +28,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ request, controller }) => { const url = new URL(request.url) diff --git a/test/modules/http/intercept/http-client-request-agent.test.ts b/test/modules/http/intercept/http-client-request-agent.test.ts deleted file mode 100644 index a0e1f3b68..000000000 --- a/test/modules/http/intercept/http-client-request-agent.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * @note This test suite makes sure that us patching both `ClientRequest` - * and `http.*`/`https.*` request-issuing methods that create that `ClientRequest` - * does not result in duplicate mock HTTP sockets/agents being created. - */ -import { beforeAll, afterEach, afterAll, it, expect } from 'vitest' -import http from 'node:http' -import https from 'node:https' -import { DeferredPromise } from '@open-draft/deferred-promise' -import type { MockHttpSocket } from '../../../../src/interceptors/ClientRequest/MockHttpSocket' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { - MockAgent, - MockHttpsAgent, -} from '../../../../src/interceptors/ClientRequest/agents' - -const interceptor = new ClientRequestInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterEach(() => { - interceptor.removeAllListeners() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('reuses the same mock socket for an HTTP ClientRequest and its agent', async () => { - const socketPromise = new DeferredPromise() - const request = http - .get('http://localhost/does-not-matter') - .on('socket', (socket) => socketPromise.resolve(socket as MockHttpSocket)) - .on('error', () => {}) - - const requestAgent = Reflect.get(request, 'agent') as http.Agent - expect(requestAgent).toBeInstanceOf(MockAgent) - - const socket = await socketPromise - expect( - socket['connectionOptions'].agent, - 'Request agent must equal to the socket agent' - ).toEqual(requestAgent) -}) - -it('reuses the same mock socket for an HTTPS ClientRequest and its agent', async () => { - const socketPromise = new DeferredPromise() - const request = https - .get('https://localhost/does-not-matter') - .on('socket', (socket) => socketPromise.resolve(socket as MockHttpSocket)) - .on('error', () => {}) - - const requestAgent = Reflect.get(request, 'agent') as https.Agent - expect(requestAgent).toBeInstanceOf(MockHttpsAgent) - - const socket = await socketPromise - expect( - socket['connectionOptions'].agent, - 'Request agent must equal to the socket agent' - ).toEqual(requestAgent) -}) diff --git a/test/modules/http/intercept/http-client-request.test.ts b/test/modules/http/intercept/http-client-request.test.ts index 5b395601f..663c55cb1 100644 --- a/test/modules/http/intercept/http-client-request.test.ts +++ b/test/modules/http/intercept/http-client-request.test.ts @@ -1,7 +1,8 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' +import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' import { RequestController } from '../../../../src/RequestController' import { HttpRequestEventMap } from '../../../../src/glossary' @@ -12,16 +13,15 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - await httpServer.listen() interceptor.apply() + await httpServer.listen() }) afterEach(() => { - vi.resetAllMocks() interceptor.removeAllListeners() }) @@ -135,7 +135,11 @@ it('intercepts an HTTPS ClientRequest request with URL string', async () => { vi.fn<(...args: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) - const req = new http.ClientRequest(url) + + const req = new http.ClientRequest(url, { + // @ts-expect-error `ClientRequest` constructor signature is wrong. + agent: https.globalAgent, + }) req.setHeader('x-custom-header', 'yes') req.end() @@ -165,7 +169,10 @@ it('intercepts an HTTPS ClientRequest request with URL instance', async () => { vi.fn<(...args: HttpRequestEventMap['request']) => void>() interceptor.on('request', requestListener) - const req = new http.ClientRequest(url) + const req = new http.ClientRequest(url, { + // @ts-expect-error `ClientRequest` constructor signature is wrong. + agent: https.globalAgent, + }) req.setHeader('x-custom-header', 'yes') req.end() @@ -196,6 +203,7 @@ it('intercepts an HTTPS ClientRequest request with request options', async () => interceptor.on('request', requestListener) const req = new http.ClientRequest({ + agent: https.globalAgent, protocol: 'https:', hostname: url.hostname, port: url.port, diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index 609c4b90c..82c976455 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -1,7 +1,7 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' import { RequestController } from '../../../../src/RequestController' import { HttpRequestEventMap } from '../../../../src/glossary' @@ -12,10 +12,7 @@ const httpServer = new HttpServer((app) => { }) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() - -const interceptor = new ClientRequestInterceptor() -interceptor.on('request', resolver) +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { await httpServer.listen() @@ -23,6 +20,7 @@ beforeAll(async () => { }) afterEach(() => { + interceptor.removeAllListeners() vi.resetAllMocks() }) @@ -32,6 +30,11 @@ afterAll(async () => { }) it('intercepts an http.get request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + + interceptor.on('request', requestListener) + const url = httpServer.http.url('/user?id=123') const req = http.get(url, { headers: { @@ -40,9 +43,9 @@ it('intercepts an http.get request', async () => { }) const { text } = await waitForClientRequest(req) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') expect(request.url).toBe(url) @@ -56,34 +59,40 @@ it('intercepts an http.get request', async () => { expect(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(text()).resolves.toBe('user-body') }) it('intercepts an http.get request given RequestOptions without a protocol', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + + interceptor.on('request', requestListener) + // Create a request with `RequestOptions` without an explicit "protocol". // Since request is done via `http.get`, the "http:" protocol must be inferred. - const req = http.get({ + const request = http.get({ host: httpServer.http.address.host, port: httpServer.http.address.port, path: '/user?id=123', }) - const { text } = await waitForClientRequest(req) + const { text } = await waitForClientRequest(request) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request: interceptedRequest, requestId, controller }] = + requestListener.mock.calls[0] - expect(request.method).toBe('GET') - expect(request.url).toBe(httpServer.http.url('/user?id=123')) - expect(request.headers.get('host')).toBe( - `${httpServer.http.address.host}:${httpServer.http.address.port}` - ) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) - expect(controller).toBeInstanceOf(RequestController) + expect.soft(interceptedRequest.method).toBe('GET') + expect.soft(interceptedRequest.url).toBe(httpServer.http.url('/user?id=123')) + expect + .soft(interceptedRequest.headers.get('host')) + .toBe(`${httpServer.http.address.host}:${httpServer.http.address.port}`) + expect.soft(interceptedRequest.credentials).toBe('same-origin') + expect.soft(interceptedRequest.body).toBe(null) + expect.soft(controller).toBeInstanceOf(RequestController) - expect(requestId).toMatch(REQUEST_ID_REGEXP) + expect.soft(requestId).toMatch(REQUEST_ID_REGEXP) // Must receive the original response. - expect(await text()).toBe('user-body') + await expect(text()).resolves.toBe('user-body') }) diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 8c75a41aa..06a2b4fd4 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -1,11 +1,11 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import type { RequestHandler } from 'express' import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src/glossary' import { RequestController } from '../../../../src/RequestController' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (_req, res) => { @@ -18,9 +18,7 @@ const httpServer = new HttpServer((app) => { app.head('/user', handleUserRequest) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() -const interceptor = new ClientRequestInterceptor() -interceptor.on('request', resolver) +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { await httpServer.listen() @@ -28,7 +26,7 @@ beforeAll(async () => { }) afterEach(() => { - vi.resetAllMocks() + interceptor.removeAllListeners() }) afterAll(async () => { @@ -37,6 +35,10 @@ afterAll(async () => { }) it('intercepts a HEAD request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.http.url('/user?id=123') const req = http.request(url, { method: 'HEAD', @@ -47,9 +49,9 @@ it('intercepts a HEAD request', async () => { req.end() await waitForClientRequest(req) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('HEAD') expect(request.url).toBe(url) @@ -65,6 +67,10 @@ it('intercepts a HEAD request', async () => { }) it('intercepts a GET request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.http.url('/user?id=123') const req = http.request(url, { method: 'GET', @@ -75,9 +81,9 @@ it('intercepts a GET request', async () => { req.end() await waitForClientRequest(req) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') expect(request.url).toBe(url) @@ -93,6 +99,10 @@ it('intercepts a GET request', async () => { }) it('intercepts a POST request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.http.url('/user?id=123') const req = http.request(url, { method: 'POST', @@ -106,9 +116,9 @@ it('intercepts a POST request', async () => { await waitForClientRequest(req) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('POST') expect(request.url).toBe(url) @@ -117,13 +127,17 @@ it('intercepts a POST request', async () => { 'x-custom-header': 'yes', }) expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('post-payload') + await expect(request.text()).resolves.toBe('post-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) }) it('intercepts a PUT request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.http.url('/user?id=123') const req = http.request(url, { method: 'PUT', @@ -136,9 +150,9 @@ it('intercepts a PUT request', async () => { req.end() await waitForClientRequest(req) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('PUT') expect(request.url).toBe(url) @@ -147,13 +161,17 @@ it('intercepts a PUT request', async () => { 'x-custom-header': 'yes', }) expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('put-payload') + await expect(request.text()).resolves.toBe('put-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) }) it('intercepts a PATCH request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.http.url('/user?id=123') const req = http.request(url, { method: 'PATCH', @@ -166,9 +184,9 @@ it('intercepts a PATCH request', async () => { req.end() await waitForClientRequest(req) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('PATCH') expect(request.url).toBe(url) @@ -177,13 +195,17 @@ it('intercepts a PATCH request', async () => { 'x-custom-header': 'yes', }) expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('patch-payload') + await expect(request.text()).resolves.toBe('patch-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) }) it('intercepts a DELETE request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.http.url('/user?id=123') const req = http.request(url, { method: 'DELETE', @@ -194,9 +216,9 @@ it('intercepts a DELETE request', async () => { req.end() await waitForClientRequest(req) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('DELETE') expect(request.url).toBe(url) @@ -212,6 +234,10 @@ it('intercepts a DELETE request', async () => { }) it('intercepts an http.request given RequestOptions without a protocol', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + // Create a request with `RequestOptions` without an explicit "protocol". // Since request is done via `http.get`, the "http:" protocol must be inferred. const req = http.request({ @@ -222,9 +248,9 @@ it('intercepts an http.request given RequestOptions without a protocol', async ( req.end() await waitForClientRequest(req) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') expect(request.url).toBe(httpServer.http.url('/user?id=123')) @@ -236,6 +262,10 @@ it('intercepts an http.request given RequestOptions without a protocol', async ( }) it('intercepts an http.request path in url and options', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const callback = vi.fn() const req = http.request( new URL(httpServer.http.url('/one')), @@ -245,9 +275,9 @@ it('intercepts an http.request path in url and options', async () => { req.end() await waitForClientRequest(req) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request, requestId, controller }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') expect(request.url).toBe(httpServer.http.url('/two')) @@ -260,6 +290,10 @@ it('intercepts an http.request path in url and options', async () => { }) it('intercepts an http.request with custom "auth" option', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const auth = 'john:secret123' const req = http.request({ host: httpServer.http.address.host, @@ -269,9 +303,9 @@ it('intercepts an http.request with custom "auth" option', async () => { req.end() await waitForClientRequest(req) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request }] = resolver.mock.calls[0] + const [{ request }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') expect(request.url).toBe(httpServer.http.url('/')) @@ -281,6 +315,10 @@ it('intercepts an http.request with custom "auth" option', async () => { }) it('intercepts an http.request with a URL with "username" and "password"', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const username = 'john' const password = 'secret123' const req = http.request( @@ -292,9 +330,9 @@ it('intercepts an http.request with a URL with "username" and "password"', async req.end() await waitForClientRequest(req) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request }] = resolver.mock.calls[0] + const [{ request }] = requestListener.mock.calls[0] expect(request.method).toBe('GET') expect(request.url).toBe(httpServer.http.url('/')) diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index 3c420d024..1085372d9 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -1,10 +1,10 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import https from 'https' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src/glossary' import { RequestController } from '../../../../src/RequestController' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/user', (req, res) => { @@ -13,7 +13,7 @@ const httpServer = new HttpServer((app) => { }) const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', resolver) beforeAll(async () => { diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index e602d99c2..bdfa4928c 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -1,11 +1,11 @@ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import https from 'https' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import https from 'node:https' import { RequestHandler } from 'express' import { HttpServer } from '@open-draft/test-server/http' import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' import { HttpRequestEventMap } from '../../../../src' import { RequestController } from '../../../../src/RequestController' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { const handleUserRequest: RequestHandler = (req, res) => { @@ -20,17 +20,15 @@ const httpServer = new HttpServer((app) => { app.head('/user', handleUserRequest) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() -const interceptor = new ClientRequestInterceptor() -interceptor.on('request', resolver) +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { - await httpServer.listen() interceptor.apply() + await httpServer.listen() }) afterEach(() => { - vi.resetAllMocks() + interceptor.removeAllListeners() }) afterAll(async () => { @@ -39,176 +37,211 @@ afterAll(async () => { }) it('intercepts a HEAD request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.https.url('/user?id=123') - const req = https.request(url, { + const request = https.request(url, { rejectUnauthorized: false, method: 'HEAD', headers: { 'x-custom-header': 'yes', }, }) - req.end() - await waitForClientRequest(req) + request.end() + await waitForClientRequest(request) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request: interceptedRequest, requestId, controller }] = + requestListener.mock.calls[0] - expect(request.method).toBe('HEAD') - expect(request.url).toBe(httpServer.https.url('/user?id=123')) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + expect(interceptedRequest.method).toBe('HEAD') + expect(interceptedRequest.url).toBe(httpServer.https.url('/user?id=123')) + expect(interceptedRequest.credentials).toBe('same-origin') + expect(interceptedRequest.body).toBe(null) expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) }) it('intercepts a GET request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.https.url('/user?id=123') - const req = https.request(url, { + const request = https.request(url, { rejectUnauthorized: false, method: 'GET', headers: { 'x-custom-header': 'yes', }, }) - req.end() - await waitForClientRequest(req) + request.end() + await waitForClientRequest(request) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request: interceptedRequest, requestId, controller }] = + requestListener.mock.calls[0] - expect(request.method).toBe('GET') - expect(request.url).toBe(httpServer.https.url('/user?id=123')) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + expect(interceptedRequest.method).toBe('GET') + expect(interceptedRequest.url).toBe(httpServer.https.url('/user?id=123')) + expect(interceptedRequest.credentials).toBe('same-origin') + expect(interceptedRequest.body).toBe(null) expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) }) it('intercepts a POST request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.https.url('/user?id=123') - const req = https.request(url, { + const request = https.request(url, { rejectUnauthorized: false, method: 'POST', headers: { 'x-custom-header': 'yes', }, }) - req.write('post-payload') - req.end() - await waitForClientRequest(req) + request.write('post-payload') + request.end() + await waitForClientRequest(request) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request: interceptedRequest, requestId, controller }] = + requestListener.mock.calls[0] - expect(request.method).toBe('POST') - expect(request.url).toBe(httpServer.https.url('/user?id=123')) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('post-payload') + expect(interceptedRequest.method).toBe('POST') + expect(interceptedRequest.url).toBe(httpServer.https.url('/user?id=123')) + expect(interceptedRequest.credentials).toBe('same-origin') + await expect(interceptedRequest.text()).resolves.toBe('post-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) }) it('intercepts a PUT request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.https.url('/user?id=123') - const req = https.request(url, { + const request = https.request(url, { rejectUnauthorized: false, method: 'PUT', headers: { 'x-custom-header': 'yes', }, }) - req.write('put-payload') - req.end() - await waitForClientRequest(req) + request.write('put-payload') + request.end() + await waitForClientRequest(request) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request: interceptedRequest, requestId, controller }] = + requestListener.mock.calls[0] - expect(request.method).toBe('PUT') - expect(request.url).toBe(httpServer.https.url('/user?id=123')) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('put-payload') + expect(interceptedRequest.method).toBe('PUT') + expect(interceptedRequest.url).toBe(httpServer.https.url('/user?id=123')) + expect(interceptedRequest.credentials).toBe('same-origin') + await expect(interceptedRequest.text()).resolves.toBe('put-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) }) it('intercepts a PATCH request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.https.url('/user?id=123') - const req = https.request(url, { + const request = https.request(url, { rejectUnauthorized: false, method: 'PATCH', headers: { 'x-custom-header': 'yes', }, }) - req.write('patch-payload') - req.end() - await waitForClientRequest(req) + request.write('patch-payload') + request.end() + await waitForClientRequest(request) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request: interceptedRequest, requestId, controller }] = + requestListener.mock.calls[0] - expect(request.method).toBe('PATCH') - expect(request.url).toBe(httpServer.https.url('/user?id=123')) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('patch-payload') + expect(interceptedRequest.method).toBe('PATCH') + expect(interceptedRequest.url).toBe(httpServer.https.url('/user?id=123')) + expect(interceptedRequest.credentials).toBe('same-origin') + await expect(interceptedRequest.text()).resolves.toBe('patch-payload') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) }) it('intercepts a DELETE request', async () => { + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + const url = httpServer.https.url('/user?id=123') - const req = https.request(url, { + const request = https.request(url, { rejectUnauthorized: false, method: 'DELETE', headers: { 'x-custom-header': 'yes', }, }) - req.end() - await waitForClientRequest(req) + request.end() + await waitForClientRequest(request) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request: interceptedRequest, requestId, controller }] = + requestListener.mock.calls[0] - expect(request.method).toBe('DELETE') - expect(request.url).toBe(httpServer.https.url('/user?id=123')) - expect(request.credentials).toBe('same-origin') - expect(await request.text()).toBe('') + expect(interceptedRequest.method).toBe('DELETE') + expect(interceptedRequest.url).toBe(httpServer.https.url('/user?id=123')) + expect(interceptedRequest.credentials).toBe('same-origin') + await expect(interceptedRequest.text()).resolves.toBe('') expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) }) it('intercepts an http.request request given RequestOptions without a protocol', async () => { - const req = https.request({ + const requestListener = + vi.fn<(...args: HttpRequestEventMap['request']) => void>() + interceptor.on('request', requestListener) + + const request = https.request({ rejectUnauthorized: false, host: httpServer.https.address.host, port: httpServer.https.address.port, path: '/user?id=123', }) - req.end() - await waitForClientRequest(req) + request.end() + await waitForClientRequest(request) - expect(resolver).toHaveBeenCalledTimes(1) + expect(requestListener).toHaveBeenCalledTimes(1) - const [{ request, requestId, controller }] = resolver.mock.calls[0] + const [{ request: interceptedRequest, requestId, controller }] = + requestListener.mock.calls[0] - expect(request.method).toBe('GET') - expect(request.url).toBe(httpServer.https.url('/user?id=123')) - expect(request.credentials).toBe('same-origin') - expect(request.body).toBe(null) + expect(interceptedRequest.method).toBe('GET') + expect(interceptedRequest.url).toBe(httpServer.https.url('/user?id=123')) + expect(interceptedRequest.credentials).toBe('same-origin') + expect(interceptedRequest.body).toBe(null) expect(controller).toBeInstanceOf(RequestController) expect(requestId).toMatch(REQUEST_ID_REGEXP) diff --git a/test/modules/http/regressions/http-concurrent-different-response-source.test.ts b/test/modules/http/regressions/http-concurrent-different-response-source.test.ts index c0b34c8bb..3a13a15d2 100644 --- a/test/modules/http/regressions/http-concurrent-different-response-source.test.ts +++ b/test/modules/http/regressions/http-concurrent-different-response-source.test.ts @@ -1,11 +1,7 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +// @vitest-environment node import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { httpGet } from '../../../helpers' -import { sleep } from '../../../../test/helpers' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { httpGet, sleep } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/', async (req, res) => { @@ -14,7 +10,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -50,9 +46,9 @@ it('handles concurrent requests with different response sources', async () => { }), ]) - expect(requests[0].res.statusCode).toEqual(201) - expect(requests[0].resBody).toEqual('mocked-response') + expect.soft(requests[0].res.statusCode).toBe(201) + expect.soft(requests[0].resBody).toBe('mocked-response') - expect(requests[1].res.statusCode).toEqual(200) - expect(requests[1].resBody).toEqual('original-response') + expect.soft(requests[1].res.statusCode).toBe(200) + expect.soft(requests[1].resBody).toBe('original-response') }) diff --git a/test/modules/http/regressions/http-concurrent-same-host.test.ts b/test/modules/http/regressions/http-concurrent-same-host.test.ts index d90efbdab..feae3e2be 100644 --- a/test/modules/http/regressions/http-concurrent-same-host.test.ts +++ b/test/modules/http/regressions/http-concurrent-same-host.test.ts @@ -2,13 +2,13 @@ * @vitest-environment node * @see https://github.com/mswjs/interceptors/issues/2 */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' let requests: Array = [] -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() + interceptor.on('request', ({ request, controller }) => { requests.push(request) controller.respondWith(new Response()) @@ -42,16 +42,16 @@ it('resolves multiple concurrent requests to the same host independently', async promisifyClientRequest(() => { return http.get('http://httpbin.org/get') }), - // promisifyClientRequest(() => { - // return http.get('http://httpbin.org/get?header=abc', { - // headers: { 'x-custom-header': 'abc' }, - // }) - // }), - // promisifyClientRequest(() => { - // return http.get('http://httpbin.org/get?header=123', { - // headers: { 'x-custom-header': '123' }, - // }) - // }), + promisifyClientRequest(() => { + return http.get('http://httpbin.org/get?header=abc', { + headers: { 'x-custom-header': 'abc' }, + }) + }), + promisifyClientRequest(() => { + return http.get('http://httpbin.org/get?header=123', { + headers: { 'x-custom-header': '123' }, + }) + }), ]) for (const request of requests) { diff --git a/test/modules/http/regressions/http-empty-readable-stream-response.test.ts b/test/modules/http/regressions/http-empty-readable-stream-response.test.ts index 67346e448..fb2275ce2 100644 --- a/test/modules/http/regressions/http-empty-readable-stream-response.test.ts +++ b/test/modules/http/regressions/http-empty-readable-stream-response.test.ts @@ -1,12 +1,9 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -17,7 +14,7 @@ afterAll(() => { }) it('responds to a request with an empty ReadableStream', async () => { - interceptor.once('request', ({ controller }) => { + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ start(controller) { controller.close() @@ -31,5 +28,5 @@ it('responds to a request with an empty ReadableStream', async () => { expect(res.statusCode).toBe(200) expect(res.statusMessage).toBe('OK') - expect(await text()).toBe('') + await expect(text()).resolves.toBe('') }) diff --git a/test/modules/http/regressions/http-max-listeners-exceeded-warning.test.ts b/test/modules/http/regressions/http-max-listeners-exceeded-warning.test.ts index 53f1e53dc..73f77d634 100644 --- a/test/modules/http/regressions/http-max-listeners-exceeded-warning.test.ts +++ b/test/modules/http/regressions/http-max-listeners-exceeded-warning.test.ts @@ -2,10 +2,9 @@ /** * @see https://github.com/mswjs/interceptors/pull/706 */ -import { it, expect, beforeAll, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { @@ -18,7 +17,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() diff --git a/test/modules/http/regressions/http-post-missing-first-bytes.test.ts b/test/modules/http/regressions/http-post-missing-first-bytes.test.ts index 5a3193bdf..d6398f3c1 100644 --- a/test/modules/http/regressions/http-post-missing-first-bytes.test.ts +++ b/test/modules/http/regressions/http-post-missing-first-bytes.test.ts @@ -2,14 +2,14 @@ /** * @see https://github.com/mswjs/msw/issues/2309 */ +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import path from 'node:path' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { vi, afterAll, beforeAll, afterEach, it, expect } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import superagent from 'superagent' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.post('/upload', (req, res) => { diff --git a/test/modules/http/regressions/http-socket-timeout.test.ts b/test/modules/http/regressions/http-socket-timeout.test.ts index 8e138fb15..7f0199f3f 100644 --- a/test/modules/http/regressions/http-socket-timeout.test.ts +++ b/test/modules/http/regressions/http-socket-timeout.test.ts @@ -1,7 +1,4 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node import { ChildProcess, spawn } from 'child_process' let child: ChildProcess diff --git a/test/modules/http/regressions/http-socket-timeout.ts b/test/modules/http/regressions/http-socket-timeout.ts index 255ceaaa7..06ceef93c 100644 --- a/test/modules/http/regressions/http-socket-timeout.ts +++ b/test/modules/http/regressions/http-socket-timeout.ts @@ -6,10 +6,10 @@ * due to the unterminated socket. */ import { it, expect, beforeAll, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' const httpServer = new HttpServer((app) => { app.get('/resource', (_req, res) => { @@ -17,9 +17,9 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() interceptor.on('request', ({ controller }) => { - controller.respondWith(new Response('hello world', { status: 301 })) + controller.respondWith(new Response('hello world', { status: 201 })) }) beforeAll(async () => { @@ -44,5 +44,5 @@ it('supports custom socket timeout on the HTTP request', async () => { request.end() const response = await responseReceived - expect(response.statusCode).toBe(301) + expect(response.statusCode).toBe(201) }) diff --git a/test/modules/http/response/http-await-response-event.test.ts b/test/modules/http/response/http-await-response-event.test.ts index f8786edf3..ac6a2e427 100644 --- a/test/modules/http/response/http-await-response-event.test.ts +++ b/test/modules/http/response/http-await-response-event.test.ts @@ -1,7 +1,7 @@ -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { @@ -10,7 +10,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -44,7 +44,7 @@ it('awaits asynchronous response event listener for a mocked response', async () const { text } = await waitForClientRequest(request) markStep(4) - expect(await text()).toBe('hello world') + await expect(text()).resolves.toBe('hello world') expect(markStep).toHaveBeenNthCalledWith(1, 1) expect(markStep).toHaveBeenNthCalledWith(2, 2) @@ -66,7 +66,7 @@ it('awaits asynchronous response event listener for the original response', asyn const { text } = await waitForClientRequest(request) markStep(4) - expect(await text()).toBe('original response') + await expect(text()).resolves.toBe('original response') expect(markStep).toHaveBeenNthCalledWith(1, 1) expect(markStep).toHaveBeenNthCalledWith(2, 2) diff --git a/test/modules/http/response/http-empty-response.test.ts b/test/modules/http/response/http-empty-response.test.ts index 427ae1aa9..f04149d59 100644 --- a/test/modules/http/response/http-empty-response.test.ts +++ b/test/modules/http/response/http-empty-response.test.ts @@ -1,23 +1,24 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() }) +afterEach(() => { + interceptor.removeAllListeners() +}) + afterAll(() => { interceptor.dispose() }) it('supports responding with an empty mocked response', async () => { - interceptor.once('request', ({ controller }) => { + interceptor.on('request', ({ controller }) => { // Responding with an empty response must // translate to 200 OK with an empty body. controller.respondWith(new Response()) @@ -26,10 +27,8 @@ it('supports responding with an empty mocked response', async () => { const request = http.get('http://localhost') const { res, text } = await waitForClientRequest(request) - expect(res.statusCode).toBe(200) - // Must not set any response headers that were not - // explicitly provided in the mocked response. - expect(res.headers).toEqual({}) - expect(res.rawHeaders).toEqual([]) - expect(await text()).toBe('') + expect.soft(res.statusCode).toBe(200) + expect.soft(res.headers).toEqual({}) + expect.soft(res.rawHeaders).toEqual([]) + await expect.soft(text()).resolves.toBe('') }) diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 074cb3f01..c29fae426 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -1,20 +1,20 @@ -import { it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' -import https from 'https' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import http from 'node:http' +import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -import { waitForClientRequest } from '../../../helpers' +import { sleep, waitForClientRequest } from '../../../helpers' const httpServer = new HttpServer((app) => { app.get('/', (_req, res) => { - res.status(200).send('/') + res.send('/') }) app.get('/get', (_req, res) => { - res.status(200).send('/get') + res.send('/get') }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() + interceptor.on('request', ({ request, controller }) => { const url = new URL(request.url) @@ -36,9 +36,8 @@ interceptor.on('request', ({ request, controller }) => { }) beforeAll(async () => { - await httpServer.listen() - interceptor.apply() + await httpServer.listen() }) afterAll(async () => { @@ -47,8 +46,8 @@ afterAll(async () => { }) it('responds to a handled request issued by "http.get"', async () => { - const req = http.get('http://any.thing/non-existing') - const { res, text } = await waitForClientRequest(req) + const request = http.get('http://any.thing/non-existing') + const { res, text } = await waitForClientRequest(request) expect(res).toMatchObject>({ statusCode: 301, @@ -57,12 +56,12 @@ it('responds to a handled request issued by "http.get"', async () => { 'content-type': 'text/plain', }, }) - expect(await text()).toEqual('mocked') + await expect(text()).resolves.toEqual('mocked') }) it('responds to a handled request issued by "https.get"', async () => { - const req = https.get('https://any.thing/non-existing') - const { res, text } = await waitForClientRequest(req) + const request = https.get('https://any.thing/non-existing') + const { res, text } = await waitForClientRequest(request) expect(res).toMatchObject>({ statusCode: 301, @@ -71,89 +70,111 @@ it('responds to a handled request issued by "https.get"', async () => { 'content-type': 'text/plain', }, }) - expect(await text()).toEqual('mocked') + await expect(text()).resolves.toEqual('mocked') }) -it('bypasses an unhandled request issued by "http.get"', async () => { - const req = http.get(httpServer.http.url('/get')) - const { res, text } = await waitForClientRequest(req) +it.only('bypasses an unhandled request issued by "http.get"', async () => { + const request = http.get(httpServer.http.url('/get')) + const { res, text } = await waitForClientRequest(request) expect(res).toMatchObject>({ statusCode: 200, statusMessage: 'OK', }) - expect(await text()).toEqual('/get') + await expect(text()).resolves.toEqual('/get') }) -it('bypasses an unhandled request issued by "https.get"', async () => { - const req = https.get(httpServer.https.url('/get'), { +it.only('bypasses an unhandled request issued by "https.get"', async () => { + const request = https.get(httpServer.https.url('/get'), { rejectUnauthorized: false, }) - const { res, text } = await waitForClientRequest(req) + const { res, text } = await waitForClientRequest(request) expect(res).toMatchObject>({ statusCode: 200, statusMessage: 'OK', }) - expect(await text()).toEqual('/get') + await expect(text()).resolves.toEqual('/get') }) it('responds to a handled request issued by "http.request"', async () => { - const req = http.request('http://any.thing/non-existing') - req.end() - const { res, text } = await waitForClientRequest(req) + const request = http.request('http://any.thing/non-existing') + request.end() + const { res, text } = await waitForClientRequest(request) expect(res.statusCode).toBe(301) expect(res.statusMessage).toEqual('Moved Permanently') expect(res.headers).toHaveProperty('content-type', 'text/plain') - expect(await text()).toEqual('mocked') + await expect(text()).resolves.toEqual('mocked') }) it('responds to a handled request issued by "https.request"', async () => { - const req = https.request('https://any.thing/non-existing') - - req.end() - const { res, text } = await waitForClientRequest(req) - - expect(res).toMatchObject>({ + const socketErrorListener = vi.fn() + const requestErrorListener = vi.fn() + + const request = https + .request('https://any.thing/non-existing', { + timeout: 1000, + }) + .once('socket', (socket) => { + socket.on('error', socketErrorListener) + }) + .on('error', requestErrorListener) + + request.end() + + /** + * @fixme Error: This is caused by either a bug in Node.js or incorrect usage of Node.js internals. + * at new NodeError (node:internal/errors:405:5) + at assert (node:internal/assert:14:11) + at MockTlsSocket.socketOnData (node:_http_client:539:3) + at MockTlsSocket.emit (node:events:517:28) + * @see https://github.com/nodejs/node/blob/a73b575304722a3682fbec3a5fb13b39c5791342/lib/_http_client.js#L612 + */ + + const { res, text } = await waitForClientRequest(request) + + expect.soft(res).toMatchObject>({ statusCode: 301, statusMessage: 'Moved Permanently', headers: { 'content-type': 'text/plain', }, }) - expect(await text()).toEqual('mocked') + await expect.soft(text()).resolves.toEqual('mocked') + expect.soft(socketErrorListener).not.toHaveBeenCalled() + expect.soft(requestErrorListener).not.toHaveBeenCalled() }) it('bypasses an unhandled request issued by "http.request"', async () => { - const req = http.request(httpServer.http.url('/get')) - req.end() - const { res, text } = await waitForClientRequest(req) + const request = http.request(httpServer.http.url('/get')) + request.end() + const { res, text } = await waitForClientRequest(request) expect(res).toMatchObject>({ statusCode: 200, statusMessage: 'OK', }) - expect(await text()).toEqual('/get') + await expect(text()).resolves.toEqual('/get') }) it('bypasses an unhandled request issued by "https.request"', async () => { - const req = https.request(httpServer.https.url('/get'), { + const request = https.request(httpServer.https.url('/get'), { rejectUnauthorized: false, }) - req.end() - const { res, text } = await waitForClientRequest(req) + request.end() + const { res, text } = await waitForClientRequest(request) expect(res).toMatchObject>({ statusCode: 200, statusMessage: 'OK', }) - expect(await text()).toEqual('/get') + await expect(text()).resolves.toEqual('/get') }) it('throws a request error when the middleware throws an exception', async () => { - const req = http.get('http://error.me') - await waitForClientRequest(req).catch((error) => { + const request = http.get('http://error.me') + await waitForClientRequest(request).catch((error) => { expect(error.message).toEqual('Custom exception message') }) }) @@ -161,12 +182,12 @@ it('throws a request error when the middleware throws an exception', async () => it('bypasses any request after the interceptor was restored', async () => { interceptor.dispose() - const req = http.get(httpServer.http.url('/')) - const { res, text } = await waitForClientRequest(req) + const request = http.get(httpServer.http.url('/')) + const { res, text } = await waitForClientRequest(request) expect(res).toMatchObject>({ statusCode: 200, statusMessage: 'OK', }) - expect(await text()).toEqual('/') + await expect(text()).resolves.toEqual('/') }) diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index 339c3d7c7..8c219e56e 100644 --- a/test/modules/http/response/http-response-delay.test.ts +++ b/test/modules/http/response/http-response-delay.test.ts @@ -1,10 +1,10 @@ -import { it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' import { sleep, waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const httpServer = new HttpServer((app) => { app.get('/resource', (req, res) => { @@ -34,7 +34,7 @@ it('supports custom delay before responding with a mock', async () => { const requestEnd = Date.now() expect(res.statusCode).toBe(200) - expect(await text()).toBe('mocked response') + await expect(text()).resolves.toBe('mocked response') expect(requestEnd - requestStart).toBeGreaterThanOrEqual(700) }) @@ -51,6 +51,6 @@ it('supports custom delay before receiving the original response', async () => { const requestEnd = Date.now() expect(res.statusCode).toBe(200) - expect(await text()).toBe('original response') + await expect(text()).resolves.toBe('original response') expect(requestEnd - requestStart).toBeGreaterThanOrEqual(700) }) diff --git a/test/modules/http/response/http-response-error.test.ts b/test/modules/http/response/http-response-error.test.ts index 7f657ee3d..5f0ba181e 100644 --- a/test/modules/http/response/http-response-error.test.ts +++ b/test/modules/http/response/http-response-error.test.ts @@ -1,9 +1,9 @@ // @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 613cfd332..4baf998c6 100644 --- a/test/modules/http/response/http-response-patching.test.ts +++ b/test/modules/http/response/http-response-patching.test.ts @@ -1,7 +1,7 @@ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { sleep, waitForClientRequest } from '../../../helpers' const server = new HttpServer((app) => { @@ -10,7 +10,7 @@ const server = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() async function getResponse(request: Request): Promise { const url = new URL(request.url) diff --git a/test/modules/http/response/http-response-readable-stream.test.ts b/test/modules/http/response/http-response-readable-stream.test.ts index 7b20f018b..b12dbd7d2 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -1,22 +1,27 @@ -/** - * @vitest-environment node - */ -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import { performance } from 'node:perf_hooks' +// @vitest-environment node +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' -import https from 'node:https' +import { performance } from 'node:perf_hooks' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpServer } from '@open-draft/test-server/http' import { sleep, waitForClientRequest } from '../../../helpers' type ResponseChunks = Array<{ buffer: Buffer; timestamp: number }> const encoder = new TextEncoder() -const interceptor = new ClientRequestInterceptor() +const httpServer = new HttpServer((app) => { + app.get('/', (req, res) => { + res.writeHead(200) + res.destroy(new Error('stream error')) + }) +}) + +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() + await httpServer.listen() }) afterEach(() => { @@ -25,13 +30,13 @@ afterEach(() => { afterAll(async () => { interceptor.dispose() + await httpServer.close() }) it('supports ReadableStream as a mocked response', async () => { - const encoder = new TextEncoder() - interceptor.once('request', ({ controller }) => { + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ - start(controller) { + pull(controller) { controller.enqueue(encoder.encode('hello')) controller.enqueue(encoder.encode(' ')) controller.enqueue(encoder.encode('world')) @@ -41,15 +46,15 @@ it('supports ReadableStream as a mocked response', async () => { controller.respondWith(new Response(stream)) }) - const request = http.get('http://example.com/resource') + const request = http.get('http://localhost/resource') const { text } = await waitForClientRequest(request) - expect(await text()).toBe('hello world') + await expect(text()).resolves.toBe('hello world') }) it('supports delays when enqueuing chunks', async () => { - interceptor.once('request', ({ controller }) => { + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ - async start(controller) { + async pull(controller) { controller.enqueue(encoder.encode('first')) await sleep(200) @@ -74,7 +79,7 @@ it('supports delays when enqueuing chunks', async () => { const responseChunksPromise = new DeferredPromise() - const request = https.get('https://api.example.com/stream', (response) => { + const request = http.get('http://api.localhost/stream', (response) => { const chunks: ResponseChunks = [] response @@ -120,7 +125,7 @@ it('handles immediate response stream errors as request errors', async () => { controller.respondWith(new Response(stream)) }) - const request = http.get('http://localhost/resource') + const request = http.get(httpServer.http.url('/')) request.on('error', requestErrorListener) await vi.waitFor(() => { diff --git a/test/modules/http/response/http-response-transfer-encoding.test.ts b/test/modules/http/response/http-response-transfer-encoding.test.ts index b65ea4e3c..d09ff4358 100644 --- a/test/modules/http/response/http-response-transfer-encoding.test.ts +++ b/test/modules/http/response/http-response-transfer-encoding.test.ts @@ -1,10 +1,9 @@ // @vitest-environment node -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { waitForClientRequest } from '../../../helpers' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -18,7 +17,7 @@ afterAll(() => { interceptor.dispose() }) -it('responds with a mocked "transfer-encoding: chunked" response', async () => { +it('responds with a mocked "transfer-encoding: chunked" respon se', async () => { interceptor.on('request', ({ controller }) => { controller.respondWith( new Response('mock', { diff --git a/test/modules/net/compliance/net-socket-passthrough.test.ts b/test/modules/net/compliance/net-socket-passthrough.test.ts new file mode 100644 index 000000000..db4308633 --- /dev/null +++ b/test/modules/net/compliance/net-socket-passthrough.test.ts @@ -0,0 +1,118 @@ +// @vitest-environment node +import { SocketInterceptor } from '../../../../src/interceptors/net' +import net from 'node:net' +import http from 'node:http' +import { DeferredPromise } from '@open-draft/deferred-promise' + +const interceptor = new SocketInterceptor() + +beforeAll(async () => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(async () => { + interceptor.dispose() +}) + +async function defineRawServer(requestListener?: http.RequestListener): Promise { + const server = http.createServer(requestListener) + + const urlPromise = new DeferredPromise() + + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const url = new URL(typeof address === 'string' ? address : `http://${address?.address}:${address?.port}`) + urlPromise.resolve(url) + }) + + const url = await urlPromise + + return { + async [Symbol.asyncDispose]() { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) return reject(error) + resolve() + }) + }) + }, + server, + url, + buildUrl(pathname: string) { + return new URL(pathname, url) + } + } +} + +it('establishes actual server connection on passthrough', async () => { + await using testServer = await defineRawServer((req, res) => { + res.end() + }) + + interceptor.on('connection', ({ socket }) => { + socket.passthrough() + }) + + const socket = net.connect(+testServer.url.port, testServer.url.hostname) + const connectListener = vi.fn() + const errorListener = vi.fn() + + socket + .once('connect', function connectOne() { + socket.write([ + 'HEAD / HTTP/1.1', + 'Host: localhost', + 'Connection: close', + '', + '' + ].join('\r\n')) + }) + .once('connect', connectListener) + + await expect.poll(() => connectListener, { + message: 'Must emit the connect event' + }).toHaveBeenCalled() + expect(connectListener).toHaveBeenCalledTimes(1) + expect(errorListener).not.toHaveBeenCalled() +}) + +it('replays socket writes onto the passthrough connection', async () => { + await using testServer = await defineRawServer((req, res) => { + req.pipe(res) + }) + + interceptor.on('connection', ({ socket }) => { + socket.passthrough() + }) + + const socket = net.connect(+testServer.url.port, testServer.url.hostname) + + const dataListener = vi.fn() + const closeListener = vi.fn() + const errorListener = vi.fn() + + socket + .once('connect', function onceConnect() { + socket.write([ + 'POST / HTTP/1.1', + 'Host: localhost', + 'Content-Type: text/plain', + 'Content-Length: 11', + 'Connection: close', + '', + 'hello world', + ].join('\r\n')) + }) + .on('data', (chunk) => dataListener(chunk.toString())) + .on('error', errorListener) + .on('close', closeListener) + + await expect.poll(() => closeListener).toHaveBeenCalled() + expect(errorListener).not.toHaveBeenCalled() + expect(dataListener).toHaveBeenCalledTimes(1) + expect(dataListener).toHaveBeenCalledWith(expect.stringContaining('hello world')) +}) diff --git a/test/modules/net/net-http.test.ts b/test/modules/net/net-http.test.ts new file mode 100644 index 000000000..a738c44ff --- /dev/null +++ b/test/modules/net/net-http.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import { SocketInterceptor } from '../../../src/interceptors/net' +import http from 'node:http' +import { beforeAll, afterEach, afterAll, vi, it, expect } from 'vitest' +import { DeferredPromise } from '@open-draft/deferred-promise' +import { waitForClientRequest } from '../../helpers' + +export const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('intercepts an http request without any body', async () => { + const requestHeader = new DeferredPromise() + const errorListener = vi.fn() + + interceptor.on('connection', ({ socket }) => { + socket.on('error', errorListener) + socket.once('write', (chunk) => requestHeader.resolve(chunk)) + socket.push('HTTP/1.1 200 OK\r\n\r\n') + socket.push('hello world') + socket.push(null) + }) + + const request = http.get('http://localhost/resource') + const { res, text } = await waitForClientRequest(request) + + await expect.soft(requestHeader).resolves.toBe(`GET /resource HTTP/1.1\r +Host: localhost\r +Connection: close\r +\r +`) + + expect.soft(res.statusCode).toBe(200) + await expect.soft(text()).resolves.toBe('hello world') + expect.soft(errorListener).not.toHaveBeenCalled() +}) + +it('intercepts an http request with chunked request body', async () => { + const requestChunks: Array = [] + const errorListener = vi.fn() + + interceptor.on('connection', ({ socket }) => { + socket.on('error', errorListener) + socket.on('write', (chunk) => requestChunks.push(chunk.toString())) + socket.push('HTTP/1.1 200 OK\r\n\r\n') + socket.push('hello world') + socket.push(null) + }) + + const request = http.request('http://localhost/resource', { method: 'POST' }) + request.write('request') + request.end('body') + const { res, text } = await waitForClientRequest(request) + + expect.soft(requestChunks.join('\r\n')).toBe( + `POST /resource HTTP/1.1\r +Host: localhost\r +Connection: close\r +Transfer-Encoding: chunked\r +\r +7\r +\r +\r +request\r +\r +\r +4\r +\r +\r +body\r +\r +\r +0\r +\r +` + ) + + expect.soft(res.statusCode).toBe(200) + await expect.soft(text()).resolves.toBe('hello world') + expect.soft(errorListener).not.toHaveBeenCalled() +}) diff --git a/test/modules/net/net-socket.test.ts b/test/modules/net/net-socket.test.ts new file mode 100644 index 000000000..3dfc0ffa2 --- /dev/null +++ b/test/modules/net/net-socket.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import net from 'node:net' +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { SocketInterceptor } from '../../../src/interceptors/net' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('emulates socket connect event', async () => { + interceptor.on('connection', ({ socket }) => { + socket.connect() + }) + + const connectionListener = vi.fn() + const socket = net.connect(443, '127.0.0.1', connectionListener) + + await expect.poll(() => connectionListener).toHaveBeenCalledOnce() + expect.soft(socket.connecting).toBe(false) + expect.soft(socket.readyState).toBe('open') +}) + +it('spies on written packets', async () => { + const writeListener = vi.fn() + interceptor.on('connection', ({ socket }) => { + socket.on('write', writeListener) + }) + + const socket = net.connect(443, '127.0.0.1') + socket.write('hello') + socket.write(' ') + socket.write('world') + + expect.soft(writeListener).toHaveBeenCalledTimes(3) + expect + .soft(writeListener) + .toHaveBeenNthCalledWith(1, 'hello', undefined, undefined) + expect + .soft(writeListener) + .toHaveBeenNthCalledWith(2, ' ', undefined, undefined) + expect + .soft(writeListener) + .toHaveBeenNthCalledWith(3, 'world', undefined, undefined) +}) + +it('supports pushing data to the socket', async () => { + interceptor.on('connection', ({ socket }) => { + socket.push('hello') + socket.push(' ') + socket.push('world') + }) + + const socket = net.connect(443, '127.0.0.1') + const dataListener = vi.fn() + socket.on('data', dataListener) + + await expect.poll(() => dataListener).toHaveBeenCalled() + expect.soft(dataListener).toHaveBeenCalledTimes(3) + expect.soft(dataListener).toHaveBeenNthCalledWith(1, Buffer.from('hello')) + expect.soft(dataListener).toHaveBeenNthCalledWith(2, Buffer.from(' ')) + expect.soft(dataListener).toHaveBeenNthCalledWith(3, Buffer.from('world')) +}) + +it('establishes passthrough', async () => { + interceptor.on('connection', ({ socket }) => { + socket.passthrough() + }) + + const socket = net.connect(443, '127.0.0.1') + const errorListener = vi.fn() + socket.on('error', errorListener) + + await expect + .poll(() => errorListener) + .toHaveBeenCalledWith( + expect.objectContaining({ + code: 'ECONNREFUSED', + message: 'connect ECONNREFUSED 127.0.0.1:443', + }) + ) +}) diff --git a/test/modules/net/net-undici.test.ts b/test/modules/net/net-undici.test.ts new file mode 100644 index 000000000..825e1a16c --- /dev/null +++ b/test/modules/net/net-undici.test.ts @@ -0,0 +1,32 @@ +// @vitest-environment node +import { SocketInterceptor } from '../../../src/interceptors/net' +import { request } from 'undici' +import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' + +const interceptor = new SocketInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterEach(() => { + interceptor.removeAllListeners() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('mocks an undici request without any body', async () => { + const errorListener = vi.fn() + + interceptor.on('connection', ({ socket }) => { + socket.on('error', errorListener) + socket.push('HTTP/1.1 200 OK\r\n\r\n') + }) + + const response = await request('http://localhost/resource') + + expect.soft(response.statusCode).toBe(200) + expect.soft(errorListener).not.toHaveBeenCalled() +}) diff --git a/test/tsconfig.json b/test/tsconfig.json index 1aa2a4a40..4d8bf03ec 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "target": "es6" + "target": "es6", + "types": ["vitest/globals"] }, "include": ["**/*.test.ts"], "exclude": ["node_modules"] diff --git a/test/vitest.config.js b/test/vitest.config.js index 113b4fe64..d0c845114 100644 --- a/test/vitest.config.js +++ b/test/vitest.config.js @@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { root: __dirname, + globals: true, include: ['**/*.test.ts'], exclude: ['**/*.browser.test.ts'], alias: { diff --git a/tsconfig.json b/tsconfig.json index a653125f7..3c06b1822 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,8 +10,8 @@ "removeComments": false, "esModuleInterop": true, "downlevelIteration": true, - "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator"], - "types": ["@types/node"], + "lib": ["dom", "dom.iterable", "ES2022.Error", "ES2018.AsyncGenerator"], + "types": ["@types/node", "vitest/globals"], "baseUrl": ".", "paths": { "_http_common": ["./_http_common.d.ts"], @@ -21,5 +21,5 @@ } }, "include": ["src/**/*.ts"], - "exclude": ["node_modules", "**/*.test.*"] + "exclude": ["node_modules"] } diff --git a/vitest.config.mjs b/vitest.config.mjs index caadb14b1..72d8e0ce4 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { + globals: true, include: ['./src/**/*.test.ts'], }, esbuild: {