From 83ba74fdc8fe8f875e695f6f1afb695b091d4934 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sat, 2 Aug 2025 15:49:53 +0200 Subject: [PATCH 01/45] feat(wip): net interceptor --- _http_common.d.ts | 19 +- src/interceptors/net/demo.test.ts | 26 +++ src/interceptors/net/http-interceptor.ts | 178 ++++++++++++++++++ src/interceptors/net/index.ts | 54 ++++++ src/interceptors/net/mock-socket.ts | 43 +++++ src/interceptors/net/parsers.ts | 37 ++++ .../net/utils/normalize-net-connect-args.ts | 67 +++++++ 7 files changed, 413 insertions(+), 11 deletions(-) create mode 100644 src/interceptors/net/demo.test.ts create mode 100644 src/interceptors/net/http-interceptor.ts create mode 100644 src/interceptors/net/index.ts create mode 100644 src/interceptors/net/mock-socket.ts create mode 100644 src/interceptors/net/parsers.ts create mode 100644 src/interceptors/net/utils/normalize-net-connect-args.ts diff --git a/_http_common.d.ts b/_http_common.d.ts index b3db30f77..1cb16d91c 100644 --- a/_http_common.d.ts +++ b/_http_common.d.ts @@ -18,15 +18,15 @@ declare var HTTPParser: { export interface HTTPParser { new (): HTTPParser - [HTTPParser.kOnMessageBegin]: () => void - [HTTPParser.kOnHeaders]: HeadersCallback - [HTTPParser.kOnHeadersComplete]: ParserType extends 0 + [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.kOnBody]?: (chunk: Buffer) => void + [HTTPParser.kOnMessageComplete]?: () => void + [HTTPParser.kOnExecute]?: () => void + [HTTPParser.kOnTimeout]?: () => void initialize(type: ParserType, asyncResource: object): void execute(buffer: Buffer): void @@ -34,10 +34,7 @@ export interface HTTPParser { free(): void } -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/net/demo.test.ts b/src/interceptors/net/demo.test.ts new file mode 100644 index 000000000..07f3bdd66 --- /dev/null +++ b/src/interceptors/net/demo.test.ts @@ -0,0 +1,26 @@ +// @vitest-environment node +import { beforeAll, afterAll, it, expect } from 'vitest' +import { HttpRequestInterceptor } from './http-interceptor' +import { waitForClientRequest } from '../../../test/helpers' + +const interceptor = new HttpRequestInterceptor() + +beforeAll(() => { + interceptor.apply() +}) + +afterAll(() => { + interceptor.dispose() +}) + +it('???', async () => { + interceptor.on('request', ({ request, controller }) => { + console.log('REQUEST LISTENER!') + controller.respondWith(new Response('hello world')) + }) + + const http = await import('http') + const request = http.get('http://localhost/resource') + + await waitForClientRequest(request) +}) diff --git a/src/interceptors/net/http-interceptor.ts b/src/interceptors/net/http-interceptor.ts new file mode 100644 index 000000000..4f64728d4 --- /dev/null +++ b/src/interceptors/net/http-interceptor.ts @@ -0,0 +1,178 @@ +import { Readable, Writable } from 'node:stream' +import { invariant } from 'outvariant' +import { Interceptor, INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' +import { type HttpRequestEventMap } from '../../glossary' +import { FetchResponse } from '../../utils/fetchUtils' +import { SocketInterceptor } from './index' +import { HttpRequestParser } from './parsers' +import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' +import { NetworkConnectionOptions } from './utils/normalize-net-connect-args' +import { createRequestId } from '../../createRequestId' +import { emitAsync } from 'src/utils/emitAsync' +import { RequestController } from '../../RequestController' +import { handleRequest } from '../../utils/handleRequest' + +function toBuffer(data: any, encoding?: BufferEncoding): Buffer { + return Buffer.isBuffer(data) ? data : Buffer.from(data, encoding) +} + +export class HttpRequestInterceptor extends Interceptor { + static symbol = Symbol('HttpRequestInterceptor') + + constructor() { + super(HttpRequestInterceptor.symbol) + } + + public setup() { + /** @fixme Use interceptor as a singleton? */ + const interceptor = new SocketInterceptor() + interceptor.apply() + + interceptor.on('socket', ({ options, socket }) => { + socket.once('write', (chunk, encoding) => { + const firstFrame = chunk.toString() + + /** + * @fixme This is obviously rather naive + */ + if (firstFrame.includes('HTTP/1.1')) { + const method = firstFrame.split(' ')[0] + + const requestParser = createHttpRequestParserStream({ + requestOptions: { + method, + ...options, + }, + onRequest: async ({ request }) => { + console.log(1) + + const requestId = createRequestId() + const controller = new RequestController(request) + + const isRequestHandled = await handleRequest({ + request, + requestId, + controller, + emitter: this.emitter, + onResponse(response) { + console.log('MOCKED!', response.status) + }, + onRequestError(response) { + // + }, + onError(error) {}, + }) + + console.log('REQ! handled?', isRequestHandled) + + if (!isRequestHandled) { + // return socket.passthrough() + } + }, + }) + requestParser.write(toBuffer(chunk, encoding)) + socket.pipe(requestParser) + } + }) + }) + } +} + +function createHttpRequestParserStream(options: { + requestOptions: NetworkConnectionOptions & { + method: string + } + onRequest: (args: { request: Request }) => void +}) { + const requestRawHeadersBuffer: Array = [] + const requestWriteBuffer: Array = [] + let requestBodyStream: Readable | undefined + + const parser = new HttpRequestParser({ + onHeaders(rawHeaders) { + requestRawHeadersBuffer.push(...rawHeaders) + }, + onHeadersComplete( + versionMajor, + versionMinor, + rawHeaders, + _, + path, + __, + ___, + ____, + shouldKeepAlive + ) { + const method = options.requestOptions.method?.toUpperCase() || 'GET' + const baseUrl = baseUrlFromConnectionOptions(options.requestOptions) + const url = new URL(path || '', baseUrl) + + // const headers = FetchResponse.parseRawHeaders([ + // ...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 = '' + } + + requestBodyStream = new Readable({ + /** + * @note Provide the `read()` method so a `Readable` could be + * used as the actual request body (the stream calls "read()"). + */ + read() { + // If the user attempts to read the request body, + // flush the write buffer to trigger the callbacks. + // This way, if the request stream ends in the write callback, + // it will indeed end correctly. + // flushWriteBuffer() + }, + }) + + const request = new Request(url, { + method, + // headers, + credentials: 'same-origin', + // @ts-expect-error Undocumented Fetch property. + duplex: canHaveBody ? 'half' : undefined, + body: canHaveBody ? (Readable.toWeb(requestBodyStream) as any) : null, + }) + + options.onRequest({ + request, + }) + }, + onBody(chunk) { + invariant( + requestBodyStream, + 'Failed to write to a request stream: stream does not exist' + ) + + requestBodyStream.push(chunk) + }, + onMessageComplete() { + requestBodyStream?.push(null) + }, + }) + + const parserStream = new Writable({ + write(chunk, encoding, callback) { + const data = toBuffer(chunk, encoding) + requestWriteBuffer.push(data) + parser.execute(data) + callback() + }, + }) + parserStream.once('finish', () => parser.free()) + + return parserStream +} diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts new file mode 100644 index 000000000..768fbfa9b --- /dev/null +++ b/src/interceptors/net/index.ts @@ -0,0 +1,54 @@ +import net from 'node:net' +import { Interceptor } from '../../Interceptor' +import { MockSocket } from './mock-socket' +import { + NetConnectArgs, + type NetworkConnectionOptions, + normalizeNetConnectArgs, +} from './utils/normalize-net-connect-args' + +export interface SocketConnectionEventMap { + /** + * Outgoing socket connection. + */ + socket: [ + args: { + socket: MockSocket + options: NetworkConnectionOptions + } + ] +} + +export class SocketInterceptor extends Interceptor { + static symbol = Symbol('SocketInterceptor') + + constructor() { + super(SocketInterceptor.symbol) + } + + protected setup(): void { + const { + connect: originalNetConnect, + createConnection: originalCreateConnection, + } = net + + net.createConnection = (...args: Array) => { + const [options, connectionListener] = normalizeNetConnectArgs( + args as NetConnectArgs + ) + const socket = new MockSocket() + + this.emitter.emit('socket', { + options, + socket, + }) + + return socket + } + + this.subscriptions.push(() => { + net.connect = originalNetConnect + net.createConnection = originalCreateConnection + }) + } +} diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts new file mode 100644 index 000000000..7fb0f6503 --- /dev/null +++ b/src/interceptors/net/mock-socket.ts @@ -0,0 +1,43 @@ +import net from 'node:net' +import { + normalizeSocketWriteArgs, + WriteArgs, +} from '../Socket/utils/normalizeSocketWriteArgs' + +export class MockSocket extends net.Socket { + public connecting: boolean + + constructor() { + super() + this.connecting = false + this.connect() + + this._final = (callback) => callback(null) + } + + public connect() { + this.connecting = true + return this + } + + public write(...args: Array): boolean { + const [chunk, encoding, callback] = normalizeSocketWriteArgs( + args as WriteArgs + ) + this.emit('write', chunk, encoding, callback) + return true + } + + public push(chunk: any, encoding?: BufferEncoding): boolean { + this.emit('push', chunk, encoding) + return super.push(chunk, encoding) + } + + public end(...args: Array) { + const [chunk, encoding, callback] = normalizeSocketWriteArgs( + args as WriteArgs + ) + this.emit('write', chunk, encoding, callback) + return super.end.apply(this, args as any) + } +} diff --git a/src/interceptors/net/parsers.ts b/src/interceptors/net/parsers.ts new file mode 100644 index 000000000..eede6385b --- /dev/null +++ b/src/interceptors/net/parsers.ts @@ -0,0 +1,37 @@ +import { + HeadersCallback, + HTTPParser, + RequestHeadersCompleteCallback, +} from '_http_common' + +export class HttpRequestParser { + #parser: HTTPParser + + constructor(options: { + onMessageBegin?: () => void + onHeaders?: HeadersCallback + onHeadersComplete?: RequestHeadersCompleteCallback + onBody?: (chunk: Buffer) => void + onMessageComplete?: () => void + onExecute?: () => void + onTimeout?: () => void + }) { + this.#parser = new HTTPParser() + this.#parser.initialize(HTTPParser.REQUEST, {}) + this.#parser[HTTPParser.kOnMessageBegin] = options.onMessageBegin + this.#parser[HTTPParser.kOnHeaders] = options.onHeaders + this.#parser[HTTPParser.kOnHeadersComplete] = options.onHeadersComplete + this.#parser[HTTPParser.kOnBody] = options.onBody + this.#parser[HTTPParser.kOnMessageComplete] = options.onMessageComplete + this.#parser[HTTPParser.kOnExecute] = options.onExecute + this.#parser[HTTPParser.kOnTimeout] = options.onTimeout + } + + public execute(buffer: Buffer): void { + this.#parser.execute(buffer) + } + + public free(): void { + this.#parser.free() + } +} 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..53def3ee3 --- /dev/null +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -0,0 +1,67 @@ +import net from 'node:net' + +export interface NetworkConnectionOptions { + port?: number + path: string + host?: string + protocol?: string + auth?: string + localAddress?: string + localPort?: number +} + +export type NetConnectArgs = + | [options: net.NetConnectOpts, connectionListener?: () => void] + | [url: URL, connectionListener?: () => void] + | [port: number, host: string, connectionListener?: () => void] + | [path: string, connectionListener?: () => void] + +export function normalizeNetConnectArgs( + args: NetConnectArgs +): [options: NetworkConnectionOptions, connectionListener?: () => void] { + if (typeof args[0] === 'string') { + return [{ path: args[0] }, args[1]] + } + + if (typeof args[0] === 'number' && typeof args[1] === 'string') { + return [{ port: args[0], path: '', host: args[1] }, args[2]] + } + + if (typeof args[0] === 'object') { + if ('href' in args[0]) { + return [ + { + path: args[0].pathname || '', + port: +args[0].port, + host: args[0].host, + protocol: args[0].protocol, + }, + args[1], + ] + } + + if ('port' in args[0]) { + return [ + { + path: '', + port: args[0].port, + host: args[0].host, + auth: Reflect.get(args[0], 'auth'), + localAddress: args[0].localAddress, + localPort: args[0].localPort, + }, + args[1], + ] + } + + return [ + { + path: args[0].path || '', + auth: Reflect.get(args[0], 'auth'), + }, + args[1], + ] + } + + throw new Error(`Invalid arguments passed to net.connect: ${args}`) +} From 1e3b90f6b30497c652f074e63d6b427645ce1ae8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 4 Aug 2025 20:02:50 +0200 Subject: [PATCH 02/45] chore: add socket recorder --- src/interceptors/net/mock-socket.ts | 40 ++++- src/interceptors/net/socket-recorder.test.ts | 160 +++++++++++++++++++ src/interceptors/net/socket-recorder.ts | 102 ++++++++++++ 3 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 src/interceptors/net/socket-recorder.test.ts create mode 100644 src/interceptors/net/socket-recorder.ts diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 7fb0f6503..46ce40377 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -1,18 +1,29 @@ import net from 'node:net' import { normalizeSocketWriteArgs, - WriteArgs, + type WriteArgs, } from '../Socket/utils/normalizeSocketWriteArgs' +import { createSocketRecorder, type SocketRecorder } from './socket-recorder' +/** + * 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() { - super() + #recorder: SocketRecorder + + constructor(protected readonly options?: net.SocketConstructorOpts) { + super(options) this.connecting = false this.connect() this._final = (callback) => callback(null) + + this.#recorder = createSocketRecorder(this) + return this.#recorder.socket } public connect() { @@ -20,7 +31,7 @@ export class MockSocket extends net.Socket { return this } - public write(...args: Array): boolean { + public write(...args: any): boolean { const [chunk, encoding, callback] = normalizeSocketWriteArgs( args as WriteArgs ) @@ -33,11 +44,28 @@ export class MockSocket extends net.Socket { return super.push(chunk, encoding) } - public end(...args: Array) { + public end(...args: any) { const [chunk, encoding, callback] = normalizeSocketWriteArgs( args as WriteArgs ) + this.emit('write', chunk, encoding, callback) - return super.end.apply(this, args as any) + return super.end.apply(this, args) + } + + public passthrough(): net.Socket { + /** + * @fixme Get the means of creating a passthrough socket instance. + */ + const socket = foo(this.options) + this.#recorder.replay(socket) + + /** + * @todo Implement the inverse recorder: changes on the passthrough socket + * must be reflected on this MockSocket. Consumers getting properties from + * this MockSocket must receive their values from the passthrough socket. + */ + + return socket } } diff --git a/src/interceptors/net/socket-recorder.test.ts b/src/interceptors/net/socket-recorder.test.ts new file mode 100644 index 000000000..9c9cd2628 --- /dev/null +++ b/src/interceptors/net/socket-recorder.test.ts @@ -0,0 +1,160 @@ +// @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('ignores internal setters', () => { + const { socket } = createSocketRecorder(new net.Socket()) + // Calling `.setTimeout()` updates the value of the internal `._timeout` property. + socket.setTimeout(1000) + + expect( + inspectSocketRecorder(socket), + 'Must not record implied internal setter' + ).not.toEqual( + expect.arrayContaining([ + { + type: 'set', + metadata: expect.objectContaining({ property: '_timeout' }), + 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), + }, + ]) + ) + }) +}) + +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..f15a9110a --- /dev/null +++ b/src/interceptors/net/socket-recorder.ts @@ -0,0 +1,102 @@ +import net from 'node:net' + +const kSocketRecorder = Symbol('kSocketRecorder') + +export interface SocketRecorder { + socket: T + replay: (newSocket: net.Socket) => void +} + +export interface SocketRecorderEntry { + type: 'get' | 'set' | 'apply' + metadata: Record + replay: (newSocket: net.Socket) => void +} + +/** + * 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 +): SocketRecorder { + const entries: Array = [] + + Object.defineProperty(socket, kSocketRecorder, { + value: entries, + configurable: true, + enumerable: false, + }) + + 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(target, thisArg, argArray) { + if (target.name === 'destroy') { + entries.length = 0 + } + + if (target.name !== 'push') { + entries.push({ + type: 'apply', + metadata: { property }, + replay(newSocket) { + Reflect.apply(target, newSocket, argArray) + }, + }) + } + return Reflect.apply(target, thisArg, argArray) + }, + }) + } + + return Reflect.get(target, property, receiver) + }, + set(target, property, newValue, receiver) { + const defaultSetter = () => { + return Reflect.set(target, property, newValue, receiver) + } + + if (typeof property === 'symbol') { + return defaultSetter() + } + + const attributes = Object.getOwnPropertyDescriptor(target, property) + if (attributes == null || !attributes.writable) { + return defaultSetter() + } + + entries.push({ + 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 + }, + } +} + +export function inspectSocketRecorder( + socket: net.Socket +): SocketRecorder | undefined { + return Reflect.get(socket, kSocketRecorder) as SocketRecorder +} From b31dddaadb4c718f13a326584f7d4691a5daae74 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 4 Aug 2025 20:24:40 +0200 Subject: [PATCH 03/45] chore: instant replay and mirror getters for passthrough --- src/interceptors/net/mock-socket.ts | 46 +++++++++++++++++++------ src/interceptors/net/socket-recorder.ts | 25 +++++++++++--- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 46ce40377..6a9fa4a40 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -11,9 +11,14 @@ import { createSocketRecorder, type SocketRecorder } from './socket-recorder' * @note This instance is application protocol-agnostic. */ export class MockSocket extends net.Socket { + static AMBIGUOUS = 0 as const + static PASSTHROUGH = 1 as const + public connecting: boolean + #readyState: 0 | 1 #recorder: SocketRecorder + #passthroughSocket?: net.Socket constructor(protected readonly options?: net.SocketConstructorOpts) { super(options) @@ -22,7 +27,27 @@ export class MockSocket extends net.Socket { this._final = (callback) => callback(null) - this.#recorder = createSocketRecorder(this) + this.#readyState = MockSocket.AMBIGUOUS + + this.#recorder = createSocketRecorder(this, { + onEntry: (entry) => { + // Once the connection has been passthrough, replay any recorded events + // on the passthrough socket immediately. No need to store them. + if (this.#readyState === MockSocket.PASSTHROUGH) { + entry.replay(this.#passthroughSocket!) + return false + } + + return true + }, + resolveGetterValue: (target, property) => { + // Once the socket has been passthrough, resolve any getters + // against the passthrough socket, not the mock socket. + if (this.#readyState === MockSocket.PASSTHROUGH) { + return this.#passthroughSocket![property as keyof net.Socket] + } + }, + }) return this.#recorder.socket } @@ -48,24 +73,23 @@ export class MockSocket extends net.Socket { const [chunk, encoding, callback] = normalizeSocketWriteArgs( args as WriteArgs ) - this.emit('write', chunk, encoding, callback) return super.end.apply(this, args) } - public passthrough(): net.Socket { + /** + * Establishes the actual connection behind this socket. + * Replays all the consumer interaction on the passthrough socket + * and mirrors all the subsequent mock socket interactions onto the passthrough socket. + */ + public passthrough(): void { + this.#readyState = MockSocket.PASSTHROUGH + /** * @fixme Get the means of creating a passthrough socket instance. */ const socket = foo(this.options) this.#recorder.replay(socket) - - /** - * @todo Implement the inverse recorder: changes on the passthrough socket - * must be reflected on this MockSocket. Consumers getting properties from - * this MockSocket must receive their values from the passthrough socket. - */ - - return socket + this.#passthroughSocket = socket } } diff --git a/src/interceptors/net/socket-recorder.ts b/src/interceptors/net/socket-recorder.ts index f15a9110a..6f8c5d4d5 100644 --- a/src/interceptors/net/socket-recorder.ts +++ b/src/interceptors/net/socket-recorder.ts @@ -19,7 +19,15 @@ export interface SocketRecorderEntry { * so they can later be replayed on the passthrough socket. */ export function createSocketRecorder( - socket: T + socket: T, + options?: { + onEntry?: (entry: SocketRecorderEntry) => boolean + resolveGetterValue?: ( + target: any, + property: string | symbol, + receiver: any + ) => void + } ): SocketRecorder { const entries: Array = [] @@ -29,6 +37,12 @@ export function createSocketRecorder( enumerable: false, }) + const addEntry = (entry: SocketRecorderEntry) => { + if (options?.onEntry?.(entry) ?? true) { + entries.push(entry) + } + } + const proxy = new Proxy(socket, { get(target, property, receiver) { if ( @@ -43,7 +57,7 @@ export function createSocketRecorder( } if (target.name !== 'push') { - entries.push({ + addEntry({ type: 'apply', metadata: { property }, replay(newSocket) { @@ -56,7 +70,10 @@ export function createSocketRecorder( }) } - return Reflect.get(target, property, receiver) + return ( + options?.resolveGetterValue?.(target, property, receiver) ?? + Reflect.get(target, property, receiver) + ) }, set(target, property, newValue, receiver) { const defaultSetter = () => { @@ -72,7 +89,7 @@ export function createSocketRecorder( return defaultSetter() } - entries.push({ + addEntry({ type: 'set', metadata: { property, newValue }, replay(newSocket) { From 584a215d7df2d13fe4e8f0b73c20745f8dcb8d33 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 11:16:38 +0200 Subject: [PATCH 04/45] chore: imply passthrough state from existence of passthrough socket --- src/interceptors/net/mock-socket.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 6a9fa4a40..f1db4f07a 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -11,12 +11,8 @@ import { createSocketRecorder, type SocketRecorder } from './socket-recorder' * @note This instance is application protocol-agnostic. */ export class MockSocket extends net.Socket { - static AMBIGUOUS = 0 as const - static PASSTHROUGH = 1 as const - public connecting: boolean - #readyState: 0 | 1 #recorder: SocketRecorder #passthroughSocket?: net.Socket @@ -27,14 +23,12 @@ export class MockSocket extends net.Socket { this._final = (callback) => callback(null) - this.#readyState = MockSocket.AMBIGUOUS - this.#recorder = createSocketRecorder(this, { onEntry: (entry) => { // Once the connection has been passthrough, replay any recorded events // on the passthrough socket immediately. No need to store them. - if (this.#readyState === MockSocket.PASSTHROUGH) { - entry.replay(this.#passthroughSocket!) + if (this.#passthroughSocket) { + entry.replay(this.#passthroughSocket) return false } @@ -43,8 +37,8 @@ export class MockSocket extends net.Socket { resolveGetterValue: (target, property) => { // Once the socket has been passthrough, resolve any getters // against the passthrough socket, not the mock socket. - if (this.#readyState === MockSocket.PASSTHROUGH) { - return this.#passthroughSocket![property as keyof net.Socket] + if (this.#passthroughSocket) { + return this.#passthroughSocket[property as keyof net.Socket] } }, }) @@ -83,8 +77,6 @@ export class MockSocket extends net.Socket { * and mirrors all the subsequent mock socket interactions onto the passthrough socket. */ public passthrough(): void { - this.#readyState = MockSocket.PASSTHROUGH - /** * @fixme Get the means of creating a passthrough socket instance. */ From b557d5c0a5e34cd093acb0542fca49924c9ed8a3 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 11:38:25 +0200 Subject: [PATCH 05/45] test: add `net` socket tests --- src/interceptors/net/http-interceptor.ts | 2 +- src/interceptors/net/index.ts | 43 ++++++++++---- src/interceptors/net/mock-socket.ts | 19 +++++-- test/modules/net/net.socket.test.ts | 72 ++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 test/modules/net/net.socket.test.ts diff --git a/src/interceptors/net/http-interceptor.ts b/src/interceptors/net/http-interceptor.ts index 4f64728d4..d25e5d1b2 100644 --- a/src/interceptors/net/http-interceptor.ts +++ b/src/interceptors/net/http-interceptor.ts @@ -28,7 +28,7 @@ export class HttpRequestInterceptor extends Interceptor { const interceptor = new SocketInterceptor() interceptor.apply() - interceptor.on('socket', ({ options, socket }) => { + interceptor.on('connection', ({ options, socket }) => { socket.once('write', (chunk, encoding) => { const firstFrame = chunk.toString() diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 768fbfa9b..06aaac496 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -3,15 +3,12 @@ import { Interceptor } from '../../Interceptor' import { MockSocket } from './mock-socket' import { NetConnectArgs, - type NetworkConnectionOptions, normalizeNetConnectArgs, + type NetworkConnectionOptions, } from './utils/normalize-net-connect-args' export interface SocketConnectionEventMap { - /** - * Outgoing socket connection. - */ - socket: [ + connection: [ args: { socket: MockSocket options: NetworkConnectionOptions @@ -28,17 +25,43 @@ export class SocketInterceptor extends Interceptor { protected setup(): void { const { - connect: originalNetConnect, + connect: originalConnect, createConnection: originalCreateConnection, } = net - net.createConnection = (...args: Array) => { + net.connect = (...args: Array) => { const [options, connectionListener] = normalizeNetConnectArgs( args as NetConnectArgs ) - const socket = new MockSocket() + const socket = new MockSocket({ + ...args, + onConnect: connectionListener, + createConnection() { + return originalConnect.apply(originalCreateConnection, args as any) + }, + }) + + this.emitter.emit('connection', { + options, + socket, + }) + + return socket + } + + net.createConnection = (...args: Array) => { + const [options] = normalizeNetConnectArgs(args as NetConnectArgs) + const socket = new MockSocket({ + ...args, + createConnection() { + return originalCreateConnection.apply( + originalCreateConnection, + args as any + ) + }, + }) - this.emitter.emit('socket', { + this.emitter.emit('connection', { options, socket, }) @@ -47,7 +70,7 @@ export class SocketInterceptor extends Interceptor { } this.subscriptions.push(() => { - net.connect = originalNetConnect + net.connect = originalConnect net.createConnection = originalCreateConnection }) } diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index f1db4f07a..62784b516 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -5,6 +5,11 @@ import { } from '../Socket/utils/normalizeSocketWriteArgs' import { createSocketRecorder, type SocketRecorder } from './socket-recorder' +interface MockSocketConstructorOptions extends net.SocketConstructorOpts { + createConnection: () => net.Socket + onConnect?: () => 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. @@ -16,13 +21,20 @@ export class MockSocket extends net.Socket { #recorder: SocketRecorder #passthroughSocket?: net.Socket - constructor(protected readonly options?: net.SocketConstructorOpts) { + constructor(protected readonly options: MockSocketConstructorOptions) { super(options) this.connecting = false this.connect() this._final = (callback) => callback(null) + this.once('connect', () => { + if (!this.#passthroughSocket) { + this.options.onConnect?.() + this.connecting = false + } + }) + this.#recorder = createSocketRecorder(this, { onEntry: (entry) => { // Once the connection has been passthrough, replay any recorded events @@ -77,10 +89,7 @@ export class MockSocket extends net.Socket { * and mirrors all the subsequent mock socket interactions onto the passthrough socket. */ public passthrough(): void { - /** - * @fixme Get the means of creating a passthrough socket instance. - */ - const socket = foo(this.options) + const socket = this.options.createConnection() this.#recorder.replay(socket) this.#passthroughSocket = socket } diff --git a/test/modules/net/net.socket.test.ts b/test/modules/net/net.socket.test.ts new file mode 100644 index 000000000..9117e71b1 --- /dev/null +++ b/test/modules/net/net.socket.test.ts @@ -0,0 +1,72 @@ +// @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.emit('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')) +}) From 2bc1c9fc2be86b9c14571f44cc2bbd3730ebf4fa Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 14:30:10 +0200 Subject: [PATCH 06/45] chore: allow returning undefined from `onEntry` --- src/interceptors/net/mock-socket.ts | 2 -- src/interceptors/net/socket-recorder.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 62784b516..90187c949 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -43,8 +43,6 @@ export class MockSocket extends net.Socket { entry.replay(this.#passthroughSocket) return false } - - return true }, resolveGetterValue: (target, property) => { // Once the socket has been passthrough, resolve any getters diff --git a/src/interceptors/net/socket-recorder.ts b/src/interceptors/net/socket-recorder.ts index 6f8c5d4d5..8ce4fb346 100644 --- a/src/interceptors/net/socket-recorder.ts +++ b/src/interceptors/net/socket-recorder.ts @@ -21,7 +21,7 @@ export interface SocketRecorderEntry { export function createSocketRecorder( socket: T, options?: { - onEntry?: (entry: SocketRecorderEntry) => boolean + onEntry?: (entry: SocketRecorderEntry) => boolean | void resolveGetterValue?: ( target: any, property: string | symbol, @@ -38,7 +38,7 @@ export function createSocketRecorder( }) const addEntry = (entry: SocketRecorderEntry) => { - if (options?.onEntry?.(entry) ?? true) { + if (options?.onEntry?.(entry) !== false) { entries.push(entry) } } From 32fcd0f8122e22cfc7cef4301c0e2888dc2bd043 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 15:05:44 +0200 Subject: [PATCH 07/45] fix: patch `node:net` early; add `http` tests --- src/interceptors/net/index.ts | 101 ++++++++++++++++++++++-------- test/modules/net/net.http.test.ts | 91 +++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 test/modules/net/net.http.test.ts diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 06aaac496..f78ed234e 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -16,6 +16,46 @@ export interface SocketConnectionEventMap { ] } +const kImplementation = Symbol('kImplementation') +const kRestoreValue = Symbol('kRestoreValue') + +/** + * 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 { createConnection } = net +Object.defineProperties(net.createConnection, { + [kRestoreValue]: { + value: createConnection.bind(createConnection), + enumerable: true, + }, + [kImplementation]: { + value() { + return Reflect.get(net.createConnection, kRestoreValue) + }, + enumerable: true, + writable: true, + }, +}) + +function createSwitchableProxy(target: any) { + return new Proxy(target, { + apply(target, thisArg, argArray) { + return Reflect.apply( + Reflect.get(target, kImplementation), + thisArg, + argArray + ) + }, + }) +} + +net.connect = createSwitchableProxy(net.connect) +net.createConnection = createSwitchableProxy(net.createConnection) + export class SocketInterceptor extends Interceptor { static symbol = Symbol('SocketInterceptor') @@ -24,12 +64,13 @@ export class SocketInterceptor extends Interceptor { } protected setup(): void { - const { - connect: originalConnect, - createConnection: originalCreateConnection, - } = net + const originalConnect = Reflect.get(net.connect, kRestoreValue) + const originalCreateConnection = Reflect.get( + net.createConnection, + kRestoreValue + ) - net.connect = (...args: Array) => { + Reflect.set(net.connect, kImplementation, (...args: Array) => { const [options, connectionListener] = normalizeNetConnectArgs( args as NetConnectArgs ) @@ -37,7 +78,7 @@ export class SocketInterceptor extends Interceptor { ...args, onConnect: connectionListener, createConnection() { - return originalConnect.apply(originalCreateConnection, args as any) + return originalConnect.apply(originalConnect, args as any) }, }) @@ -47,31 +88,39 @@ export class SocketInterceptor extends Interceptor { }) return socket - } + }) - net.createConnection = (...args: Array) => { - const [options] = normalizeNetConnectArgs(args as NetConnectArgs) - const socket = new MockSocket({ - ...args, - createConnection() { - return originalCreateConnection.apply( - originalCreateConnection, - args as any - ) - }, - }) + Reflect.set( + net.createConnection, + kImplementation, + (...args: Array) => { + const [options] = normalizeNetConnectArgs(args as NetConnectArgs) + const socket = new MockSocket({ + ...args, + createConnection() { + return originalCreateConnection.apply( + originalCreateConnection, + args as any + ) + }, + }) - this.emitter.emit('connection', { - options, - socket, - }) + this.emitter.emit('connection', { + options, + socket, + }) - return socket - } + return socket + } + ) this.subscriptions.push(() => { - net.connect = originalConnect - net.createConnection = originalCreateConnection + Reflect.set(net.connect, kImplementation, originalConnect) + Reflect.set( + net.createConnection, + kImplementation, + originalCreateConnection + ) }) } } 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() +}) From 7e3793c3d64baa30be53abe1dfdde74e16ac3aec Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 15:13:23 +0200 Subject: [PATCH 08/45] chore: skip proxying `net.createConnection` --- src/interceptors/net/index.ts | 97 +++++++++++------------------------ 1 file changed, 31 insertions(+), 66 deletions(-) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index f78ed234e..3a92552e6 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -19,42 +19,40 @@ export interface SocketConnectionEventMap { const kImplementation = Symbol('kImplementation') const kRestoreValue = Symbol('kRestoreValue') -/** - * 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 { createConnection } = net -Object.defineProperties(net.createConnection, { - [kRestoreValue]: { - value: createConnection.bind(createConnection), - enumerable: true, - }, - [kImplementation]: { - value() { - return Reflect.get(net.createConnection, kRestoreValue) - }, - enumerable: true, - writable: true, - }, -}) +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 } = net -function createSwitchableProxy(target: any) { - return new Proxy(target, { - apply(target, thisArg, argArray) { - return Reflect.apply( - Reflect.get(target, kImplementation), - thisArg, - argArray - ) - }, + Reflect.set(net.connect, kRestoreValue, connect.bind(connect)) + Reflect.set(net.connect, kImplementation, () => { + return Reflect.get(net.connect, kRestoreValue) }) -} -net.connect = createSwitchableProxy(net.connect) -net.createConnection = createSwitchableProxy(net.createConnection) + function createSwitchableProxy(target: any) { + return new Proxy(target, { + apply(target, thisArg, argArray) { + return Reflect.apply( + Reflect.get(target, kImplementation), + thisArg, + argArray + ) + }, + }) + } + + 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 +} export class SocketInterceptor extends Interceptor { static symbol = Symbol('SocketInterceptor') @@ -65,10 +63,6 @@ export class SocketInterceptor extends Interceptor { protected setup(): void { const originalConnect = Reflect.get(net.connect, kRestoreValue) - const originalCreateConnection = Reflect.get( - net.createConnection, - kRestoreValue - ) Reflect.set(net.connect, kImplementation, (...args: Array) => { const [options, connectionListener] = normalizeNetConnectArgs( @@ -90,37 +84,8 @@ export class SocketInterceptor extends Interceptor { return socket }) - Reflect.set( - net.createConnection, - kImplementation, - (...args: Array) => { - const [options] = normalizeNetConnectArgs(args as NetConnectArgs) - const socket = new MockSocket({ - ...args, - createConnection() { - return originalCreateConnection.apply( - originalCreateConnection, - args as any - ) - }, - }) - - this.emitter.emit('connection', { - options, - socket, - }) - - return socket - } - ) - this.subscriptions.push(() => { Reflect.set(net.connect, kImplementation, originalConnect) - Reflect.set( - net.createConnection, - kImplementation, - originalCreateConnection - ) }) } } From 2b2896fd712e2eef2e30a0ee7bd96514b163a816 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 15:19:34 +0200 Subject: [PATCH 09/45] fix(MockSocket): use symbols to circumvent `this` change --- src/interceptors/net/mock-socket.ts | 34 +++++++++++++++++++---------- test/modules/net/net.socket.test.ts | 19 ++++++++++++++++ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 90187c949..71ba0a793 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -10,16 +10,19 @@ interface MockSocketConstructorOptions extends net.SocketConstructorOpts { onConnect?: () => void } +const kRecorder = Symbol('kRecorder') +const kPassthroughSocket = Symbol('kPassthroughSocket') + /** * 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 + public connecting: boolean; - #recorder: SocketRecorder - #passthroughSocket?: net.Socket + [kRecorder]: SocketRecorder; + [kPassthroughSocket]?: net.Socket constructor(protected readonly options: MockSocketConstructorOptions) { super(options) @@ -29,30 +32,37 @@ export class MockSocket extends net.Socket { this._final = (callback) => callback(null) this.once('connect', () => { - if (!this.#passthroughSocket) { + if (!this[kPassthroughSocket]) { this.options.onConnect?.() this.connecting = false } }) - this.#recorder = createSocketRecorder(this, { + this[kRecorder] = createSocketRecorder(this, { onEntry: (entry) => { + if ( + entry.type === 'apply' && + entry.metadata.property === 'passthrough' + ) { + return false + } + // Once the connection has been passthrough, replay any recorded events // on the passthrough socket immediately. No need to store them. - if (this.#passthroughSocket) { - entry.replay(this.#passthroughSocket) + 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.#passthroughSocket) { - return this.#passthroughSocket[property as keyof net.Socket] + if (this[kPassthroughSocket]) { + return this[kPassthroughSocket][property as keyof net.Socket] } }, }) - return this.#recorder.socket + return this[kRecorder].socket } public connect() { @@ -88,7 +98,7 @@ export class MockSocket extends net.Socket { */ public passthrough(): void { const socket = this.options.createConnection() - this.#recorder.replay(socket) - this.#passthroughSocket = socket + this[kRecorder].replay(socket) + this[kPassthroughSocket] = socket } } diff --git a/test/modules/net/net.socket.test.ts b/test/modules/net/net.socket.test.ts index 9117e71b1..874192fba 100644 --- a/test/modules/net/net.socket.test.ts +++ b/test/modules/net/net.socket.test.ts @@ -70,3 +70,22 @@ it('supports pushing data to the socket', async () => { 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', + }) + ) +}) From ab83bfb4bdb3a1d8f142be5e1c81f7fa1d97562b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 15:33:07 +0200 Subject: [PATCH 10/45] fix(SocketRecorder): ignore setters for internal properties --- src/interceptors/net/socket-recorder.test.ts | 19 ++++++++----------- src/interceptors/net/socket-recorder.ts | 2 +- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/interceptors/net/socket-recorder.test.ts b/src/interceptors/net/socket-recorder.test.ts index 9c9cd2628..83488cd82 100644 --- a/src/interceptors/net/socket-recorder.test.ts +++ b/src/interceptors/net/socket-recorder.test.ts @@ -49,21 +49,18 @@ describe('set', () => { it('ignores internal setters', () => { const { socket } = createSocketRecorder(new net.Socket()) - // Calling `.setTimeout()` updates the value of the internal `._timeout` property. - socket.setTimeout(1000) + socket.on('error', () => {}) expect( inspectSocketRecorder(socket), 'Must not record implied internal setter' - ).not.toEqual( - expect.arrayContaining([ - { - type: 'set', - metadata: expect.objectContaining({ property: '_timeout' }), - replay: expect.any(Function), - }, - ]) - ) + ).toEqual([ + { + type: 'apply', + metadata: { property: 'on' }, + replay: expect.any(Function), + }, + ]) }) }) diff --git a/src/interceptors/net/socket-recorder.ts b/src/interceptors/net/socket-recorder.ts index 8ce4fb346..49b0a7e31 100644 --- a/src/interceptors/net/socket-recorder.ts +++ b/src/interceptors/net/socket-recorder.ts @@ -80,7 +80,7 @@ export function createSocketRecorder( return Reflect.set(target, property, newValue, receiver) } - if (typeof property === 'symbol') { + if (typeof property === 'symbol' || property.startsWith('_')) { return defaultSetter() } From 2075b578f3efbcbb2a3f46f1c5f324f8fa6e3a01 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 16:11:50 +0200 Subject: [PATCH 11/45] fix(MockSocket): connect immediately, defer `connect` event emission --- src/interceptors/net/index.ts | 1 + src/interceptors/net/mock-socket.ts | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 3a92552e6..69581b5c3 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -75,6 +75,7 @@ export class SocketInterceptor extends Interceptor { return originalConnect.apply(originalConnect, args as any) }, }) + socket.connect() this.emitter.emit('connection', { options, diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 71ba0a793..f1bded411 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -27,17 +27,9 @@ export class MockSocket extends net.Socket { constructor(protected readonly options: MockSocketConstructorOptions) { super(options) this.connecting = false - this.connect() this._final = (callback) => callback(null) - this.once('connect', () => { - if (!this[kPassthroughSocket]) { - this.options.onConnect?.() - this.connecting = false - } - }) - this[kRecorder] = createSocketRecorder(this, { onEntry: (entry) => { if ( @@ -67,6 +59,16 @@ export class MockSocket extends net.Socket { public connect() { this.connecting = true + + this.once('connect', () => { + this.connecting = false + this.options?.onConnect?.() + }) + + queueMicrotask(() => { + this.emit('connect') + }) + return this } From 5429699a5730b62ee18470a6749244ed48795e7d Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 16:11:57 +0200 Subject: [PATCH 12/45] test: add undici test --- test/modules/net/net.undici.test.ts | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 test/modules/net/net.undici.test.ts 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() +}) From 6a5b0a65ea9fbfdf89211149cd777952f7f7ce28 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 16:13:24 +0200 Subject: [PATCH 13/45] chore: remove unused test --- src/interceptors/net/demo.test.ts | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 src/interceptors/net/demo.test.ts diff --git a/src/interceptors/net/demo.test.ts b/src/interceptors/net/demo.test.ts deleted file mode 100644 index 07f3bdd66..000000000 --- a/src/interceptors/net/demo.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -// @vitest-environment node -import { beforeAll, afterAll, it, expect } from 'vitest' -import { HttpRequestInterceptor } from './http-interceptor' -import { waitForClientRequest } from '../../../test/helpers' - -const interceptor = new HttpRequestInterceptor() - -beforeAll(() => { - interceptor.apply() -}) - -afterAll(() => { - interceptor.dispose() -}) - -it('???', async () => { - interceptor.on('request', ({ request, controller }) => { - console.log('REQUEST LISTENER!') - controller.respondWith(new Response('hello world')) - }) - - const http = await import('http') - const request = http.get('http://localhost/resource') - - await waitForClientRequest(request) -}) From 2b982f0045f07d55238c2248a8c1fdecc63b0c6b Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 16:32:04 +0200 Subject: [PATCH 14/45] chore(http): clean up --- src/interceptors/net/http-interceptor.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/interceptors/net/http-interceptor.ts b/src/interceptors/net/http-interceptor.ts index d25e5d1b2..b5618f903 100644 --- a/src/interceptors/net/http-interceptor.ts +++ b/src/interceptors/net/http-interceptor.ts @@ -32,10 +32,7 @@ export class HttpRequestInterceptor extends Interceptor { socket.once('write', (chunk, encoding) => { const firstFrame = chunk.toString() - /** - * @fixme This is obviously rather naive - */ - if (firstFrame.includes('HTTP/1.1')) { + if (firstFrame.includes('HTTP/')) { const method = firstFrame.split(' ')[0] const requestParser = createHttpRequestParserStream({ @@ -66,7 +63,7 @@ export class HttpRequestInterceptor extends Interceptor { console.log('REQ! handled?', isRequestHandled) if (!isRequestHandled) { - // return socket.passthrough() + return socket.passthrough() } }, }) From 845242233c96574698ddb1e9a8bdf349e6605934 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 16:33:13 +0200 Subject: [PATCH 15/45] chore: move http interceptor to `/interceptors` --- .../{net/http-interceptor.ts => http.ts} | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) rename src/interceptors/{net/http-interceptor.ts => http.ts} (89%) diff --git a/src/interceptors/net/http-interceptor.ts b/src/interceptors/http.ts similarity index 89% rename from src/interceptors/net/http-interceptor.ts rename to src/interceptors/http.ts index b5618f903..7915b32c1 100644 --- a/src/interceptors/net/http-interceptor.ts +++ b/src/interceptors/http.ts @@ -1,16 +1,16 @@ import { Readable, Writable } from 'node:stream' import { invariant } from 'outvariant' -import { Interceptor, INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' -import { type HttpRequestEventMap } from '../../glossary' -import { FetchResponse } from '../../utils/fetchUtils' -import { SocketInterceptor } from './index' -import { HttpRequestParser } from './parsers' -import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' -import { NetworkConnectionOptions } from './utils/normalize-net-connect-args' -import { createRequestId } from '../../createRequestId' +import { Interceptor, INTERNAL_REQUEST_ID_HEADER_NAME } from '../Interceptor' +import { type HttpRequestEventMap } from '../glossary' +import { FetchResponse } from '../utils/fetchUtils' +import { SocketInterceptor } from './net' +import { HttpRequestParser } from './net/parsers' +import { baseUrlFromConnectionOptions } from './Socket/utils/baseUrlFromConnectionOptions' +import { NetworkConnectionOptions } from './net/utils/normalize-net-connect-args' +import { createRequestId } from '../createRequestId' import { emitAsync } from 'src/utils/emitAsync' -import { RequestController } from '../../RequestController' -import { handleRequest } from '../../utils/handleRequest' +import { RequestController } from '../RequestController' +import { handleRequest } from '../utils/handleRequest' function toBuffer(data: any, encoding?: BufferEncoding): Buffer { return Buffer.isBuffer(data) ? data : Buffer.from(data, encoding) From 2c7f4e9de6b8cded77b783c8419dc5a4af907c02 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 18:11:35 +0200 Subject: [PATCH 16/45] fix(http): implement response handling --- src/interceptors/http.ts | 175 --------- .../{net/parsers.ts => http/http-parser.ts} | 0 src/interceptors/http/index.ts | 370 ++++++++++++++++++ src/interceptors/http/utils/to-buffer.ts | 3 + src/interceptors/net/socket-recorder.ts | 11 +- .../net/utils/normalize-net-connect-args.ts | 6 + .../http/response/http-empty-response.test.ts | 26 +- 7 files changed, 398 insertions(+), 193 deletions(-) delete mode 100644 src/interceptors/http.ts rename src/interceptors/{net/parsers.ts => http/http-parser.ts} (100%) create mode 100644 src/interceptors/http/index.ts create mode 100644 src/interceptors/http/utils/to-buffer.ts diff --git a/src/interceptors/http.ts b/src/interceptors/http.ts deleted file mode 100644 index 7915b32c1..000000000 --- a/src/interceptors/http.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { Readable, Writable } from 'node:stream' -import { invariant } from 'outvariant' -import { Interceptor, INTERNAL_REQUEST_ID_HEADER_NAME } from '../Interceptor' -import { type HttpRequestEventMap } from '../glossary' -import { FetchResponse } from '../utils/fetchUtils' -import { SocketInterceptor } from './net' -import { HttpRequestParser } from './net/parsers' -import { baseUrlFromConnectionOptions } from './Socket/utils/baseUrlFromConnectionOptions' -import { NetworkConnectionOptions } from './net/utils/normalize-net-connect-args' -import { createRequestId } from '../createRequestId' -import { emitAsync } from 'src/utils/emitAsync' -import { RequestController } from '../RequestController' -import { handleRequest } from '../utils/handleRequest' - -function toBuffer(data: any, encoding?: BufferEncoding): Buffer { - return Buffer.isBuffer(data) ? data : Buffer.from(data, encoding) -} - -export class HttpRequestInterceptor extends Interceptor { - static symbol = Symbol('HttpRequestInterceptor') - - constructor() { - super(HttpRequestInterceptor.symbol) - } - - public setup() { - /** @fixme Use interceptor as a singleton? */ - const interceptor = new SocketInterceptor() - interceptor.apply() - - interceptor.on('connection', ({ options, socket }) => { - socket.once('write', (chunk, encoding) => { - const firstFrame = chunk.toString() - - if (firstFrame.includes('HTTP/')) { - const method = firstFrame.split(' ')[0] - - const requestParser = createHttpRequestParserStream({ - requestOptions: { - method, - ...options, - }, - onRequest: async ({ request }) => { - console.log(1) - - const requestId = createRequestId() - const controller = new RequestController(request) - - const isRequestHandled = await handleRequest({ - request, - requestId, - controller, - emitter: this.emitter, - onResponse(response) { - console.log('MOCKED!', response.status) - }, - onRequestError(response) { - // - }, - onError(error) {}, - }) - - console.log('REQ! handled?', isRequestHandled) - - if (!isRequestHandled) { - return socket.passthrough() - } - }, - }) - requestParser.write(toBuffer(chunk, encoding)) - socket.pipe(requestParser) - } - }) - }) - } -} - -function createHttpRequestParserStream(options: { - requestOptions: NetworkConnectionOptions & { - method: string - } - onRequest: (args: { request: Request }) => void -}) { - const requestRawHeadersBuffer: Array = [] - const requestWriteBuffer: Array = [] - let requestBodyStream: Readable | undefined - - const parser = new HttpRequestParser({ - onHeaders(rawHeaders) { - requestRawHeadersBuffer.push(...rawHeaders) - }, - onHeadersComplete( - versionMajor, - versionMinor, - rawHeaders, - _, - path, - __, - ___, - ____, - shouldKeepAlive - ) { - const method = options.requestOptions.method?.toUpperCase() || 'GET' - const baseUrl = baseUrlFromConnectionOptions(options.requestOptions) - const url = new URL(path || '', baseUrl) - - // const headers = FetchResponse.parseRawHeaders([ - // ...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 = '' - } - - requestBodyStream = new Readable({ - /** - * @note Provide the `read()` method so a `Readable` could be - * used as the actual request body (the stream calls "read()"). - */ - read() { - // If the user attempts to read the request body, - // flush the write buffer to trigger the callbacks. - // This way, if the request stream ends in the write callback, - // it will indeed end correctly. - // flushWriteBuffer() - }, - }) - - const request = new Request(url, { - method, - // headers, - credentials: 'same-origin', - // @ts-expect-error Undocumented Fetch property. - duplex: canHaveBody ? 'half' : undefined, - body: canHaveBody ? (Readable.toWeb(requestBodyStream) as any) : null, - }) - - options.onRequest({ - request, - }) - }, - onBody(chunk) { - invariant( - requestBodyStream, - 'Failed to write to a request stream: stream does not exist' - ) - - requestBodyStream.push(chunk) - }, - onMessageComplete() { - requestBodyStream?.push(null) - }, - }) - - const parserStream = new Writable({ - write(chunk, encoding, callback) { - const data = toBuffer(chunk, encoding) - requestWriteBuffer.push(data) - parser.execute(data) - callback() - }, - }) - parserStream.once('finish', () => parser.free()) - - return parserStream -} diff --git a/src/interceptors/net/parsers.ts b/src/interceptors/http/http-parser.ts similarity index 100% rename from src/interceptors/net/parsers.ts rename to src/interceptors/http/http-parser.ts diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts new file mode 100644 index 000000000..77287681a --- /dev/null +++ b/src/interceptors/http/index.ts @@ -0,0 +1,370 @@ +import net from 'node:net' +import { Readable, Writable } from 'node:stream' +import { invariant } from 'outvariant' +import { Interceptor, INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' +import { type HttpRequestEventMap } from '../../glossary' +import { FetchResponse } from '../../utils/fetchUtils' +import { SocketInterceptor } from '../net' +import { HttpRequestParser } from './http-parser' +import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' +import type { NetworkConnectionOptions } from '../net/utils/normalize-net-connect-args' +import { createRequestId } from '../../createRequestId' +import { RequestController } from '../../RequestController' +import { handleRequest } from '../../utils/handleRequest' +import { toBuffer } from './utils/to-buffer' +import { getRawFetchHeaders } from '../ClientRequest/utils/recordRawHeaders' +import { isResponseError } from '../../utils/responseUtils' +import { MockSocket } from '../Socket/MockSocket' + +/** + * @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() + }) + + socketInterceptor.on('connection', ({ options, socket }) => { + socket.once('write', (chunk, encoding) => { + 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 requestParser = createHttpRequestParserStream({ + requestOptions: { + method, + ...options, + }, + onRequest: async (request) => { + const requestId = createRequestId() + const controller = new RequestController(request) + + const isRequestHandled = await handleRequest({ + request, + requestId, + controller, + emitter: this.emitter, + async onResponse(response) { + await respondWith({ + socket, + connectionOptions: options, + request, + response, + }) + }, + async onRequestError(response) { + await respondWith({ + socket, + connectionOptions: options, + request, + response, + }) + }, + onError(error) { + if (error instanceof Error) { + socket.destroy(error) + } + }, + }) + + if (!isRequestHandled) { + socket.passthrough() + } + }, + }) + + // Write the header again because at this point it's already been written. + requestParser.write(toBuffer(chunk, encoding)) + socket.pipe(requestParser) + }) + }) + } +} + +function createHttpRequestParserStream(options: { + requestOptions: NetworkConnectionOptions & { + method: string + } + onRequest: (request: Request) => void +}) { + const requestRawHeadersBuffer: Array = [] + let requestBodyStream: Readable | undefined + + const parser = new HttpRequestParser({ + onHeaders(rawHeaders) { + requestRawHeadersBuffer.push(...rawHeaders) + }, + onHeadersComplete( + versionMajor, + versionMinor, + rawHeaders, + _, + path, + __, + ___, + ____, + shouldKeepAlive + ) { + const method = options.requestOptions.method?.toUpperCase() || 'GET' + const baseUrl = baseUrlFromConnectionOptions(options.requestOptions) + const url = new URL(path || '', baseUrl) + + const headers = new Headers() + // const headers = FetchResponse.parseRawHeaders([ + // ...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 = '' + } + + requestBodyStream = new Readable({ + /** + * @note Provide the `read()` method so a `Readable` could be + * used as the actual request body (the stream calls "read()"). + */ + read() { + // If the user attempts to read the request body, + // flush the write buffer to trigger the callbacks. + // This way, if the request stream ends in the write callback, + // it will indeed end correctly. + // flushWriteBuffer() + }, + }) + + const request = new Request(url, { + method, + headers, + credentials: 'same-origin', + // @ts-expect-error Undocumented Fetch property. + duplex: canHaveBody ? 'half' : undefined, + body: canHaveBody ? (Readable.toWeb(requestBodyStream) as any) : null, + }) + + options.onRequest(request) + }, + onBody(chunk) { + invariant( + requestBodyStream, + 'Failed to write to a request stream: stream does not exist' + ) + + requestBodyStream.push(chunk) + }, + onMessageComplete() { + requestBodyStream?.push(null) + }, + }) + + const parserStream = new Writable({ + write(chunk, encoding, callback) { + parser.execute(toBuffer(chunk, encoding)) + callback() + }, + }) + + parserStream.once('finish', () => { + parser.free() + }) + + return parserStream +} + +/** + * Mocks a successful socket connection. + */ +function 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 (connectionOptions.protocol === 'https:') { + socket.emit('secure') + socket.emit('secureConnect') + socket.emit( + 'session', + connectionOptions.session || Buffer.from('mock-session-renegotiate') + ) + socket.emit('session', Buffer.from('mock-session-resume')) + } +} + +/** + * Pushes the given Fetch API `Response` onto the given socket. + * Automatically establishes a successful mock socket connection. + */ +async function 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 + } + + // Handle `Response.error()` instances. + if (isResponseError(response)) { + socket.destroy(new TypeError('Network error')) + return + } + + // Establish a mocked socket connection. + // Prior to this point, the socket has been pending. + mockConnect(socket, connectionOptions) + + /** + * @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' + ) + + // Construct a regular server response to delegate body parsing to Node.js. + const serverResponse = new ServerResponse(new IncomingMessage(socket)) + + /** + * @note Provide a dummy socket to the server response to translate all its writes + * into pushes to the underlying mocked socket. This is only needed because we + * use `ServerResponse` to skip manual response message handling on our end. + */ + serverResponse.assignSocket( + new MockSocket({ + write(chunk, encoding, callback) { + socket.push(chunk, encoding) + callback?.() + }, + read() {}, + }) + ) + + /** + * @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 + ) + + socket.once('error', (error) => { + // Destroy the mocked response if the developer destroys the socket. + serverResponse.destroy(error) + }) + + 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) { + /** + * Translate body stream errors to socket errors. + * @note We cannot use a mocked 500 response here because + * the response headers have already been written. + */ + if (error instanceof Error) { + socket.destroy(error) + } + } + } 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/socket-recorder.ts b/src/interceptors/net/socket-recorder.ts index 49b0a7e31..ec730eb44 100644 --- a/src/interceptors/net/socket-recorder.ts +++ b/src/interceptors/net/socket-recorder.ts @@ -51,21 +51,22 @@ export function createSocketRecorder( typeof target[property as keyof T] === 'function' ) { return new Proxy(target[property as keyof T] as Function, { - apply(target, thisArg, argArray) { - if (target.name === 'destroy') { + apply(fn, thisArg, argArray) { + if (fn.name === 'destroy') { entries.length = 0 } - if (target.name !== 'push') { + if (fn.name !== 'push') { addEntry({ type: 'apply', metadata: { property }, replay(newSocket) { - Reflect.apply(target, newSocket, argArray) + fn.apply(newSocket, argArray) }, }) } - return Reflect.apply(target, thisArg, argArray) + + return fn.apply(thisArg, argArray) }, }) } diff --git a/src/interceptors/net/utils/normalize-net-connect-args.ts b/src/interceptors/net/utils/normalize-net-connect-args.ts index 53def3ee3..5b687473b 100644 --- a/src/interceptors/net/utils/normalize-net-connect-args.ts +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -6,6 +6,8 @@ export interface NetworkConnectionOptions { host?: string protocol?: string auth?: string + family?: number + session?: Buffer localAddress?: string localPort?: number } @@ -47,6 +49,8 @@ export function normalizeNetConnectArgs( 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, }, @@ -57,6 +61,8 @@ export function normalizeNetConnectArgs( return [ { path: args[0].path || '', + family: Reflect.get(args[0], 'family'), + session: Reflect.get(args[0], 'session'), auth: Reflect.get(args[0], 'auth'), }, args[1], diff --git a/test/modules/http/response/http-empty-response.test.ts b/test/modules/http/response/http-empty-response.test.ts index 427ae1aa9..ad15ac7ac 100644 --- a/test/modules/http/response/http-empty-response.test.ts +++ b/test/modules/http/response/http-empty-response.test.ts @@ -1,23 +1,25 @@ -/** - * @vitest-environment node - */ -import { it, expect, beforeAll, afterAll } from 'vitest' +// @vitest-environment node +import { it, expect, beforeAll, afterAll, afterEach } 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() }) +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 +28,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('') }) From dd422e3242516c06ab5bf6b6c8fe3194e0bd21a7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 19:49:10 +0200 Subject: [PATCH 17/45] fix: invoke proxy implementation directly --- src/interceptors/net/index.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 69581b5c3..b24c84a78 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -37,11 +37,7 @@ if (Reflect.get(net.connect, kImplementation) == null) { function createSwitchableProxy(target: any) { return new Proxy(target, { apply(target, thisArg, argArray) { - return Reflect.apply( - Reflect.get(target, kImplementation), - thisArg, - argArray - ) + return Reflect.get(target, kImplementation).apply(thisArg, argArray) }, }) } From a9f693259b6b1855ad5fec88760b568bc9639787 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 5 Aug 2025 21:59:28 +0200 Subject: [PATCH 18/45] fix: do not call `socket.connect()` in the proxy --- src/interceptors/net/index.ts | 21 ++++++++++++++------- test/modules/net/net.socket.test.ts | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index b24c84a78..df12a0e6b 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -17,7 +17,7 @@ export interface SocketConnectionEventMap { } const kImplementation = Symbol('kImplementation') -const kRestoreValue = Symbol('kRestoreValue') +const kOriginalValue = Symbol('kOriginalValue') if (Reflect.get(net.connect, kImplementation) == null) { /** @@ -27,11 +27,11 @@ if (Reflect.get(net.connect, kImplementation) == null) { * * @note You MUST import the interceptor BEFORE the surface relying on "node:net". */ - const { connect } = net + const { connect: originalConnect } = net - Reflect.set(net.connect, kRestoreValue, connect.bind(connect)) + Reflect.set(net.connect, kOriginalValue, originalConnect) Reflect.set(net.connect, kImplementation, () => { - return Reflect.get(net.connect, kRestoreValue) + return Reflect.get(net.connect, kOriginalValue) }) function createSwitchableProxy(target: any) { @@ -58,7 +58,7 @@ export class SocketInterceptor extends Interceptor { } protected setup(): void { - const originalConnect = Reflect.get(net.connect, kRestoreValue) + const originalConnect = Reflect.get(net.connect, kOriginalValue) Reflect.set(net.connect, kImplementation, (...args: Array) => { const [options, connectionListener] = normalizeNetConnectArgs( @@ -68,10 +68,17 @@ export class SocketInterceptor extends Interceptor { ...args, onConnect: connectionListener, createConnection() { - return originalConnect.apply(originalConnect, args as any) + return originalConnect(...args) }, }) - socket.connect() + + /** + * @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 it handy + * for simulating a successful connection. Calling `.passthrough()` will + * tap into `net.connect()`, which calls `socket.connect()` immediately. + */ this.emitter.emit('connection', { options, diff --git a/test/modules/net/net.socket.test.ts b/test/modules/net/net.socket.test.ts index 874192fba..3dfc0ffa2 100644 --- a/test/modules/net/net.socket.test.ts +++ b/test/modules/net/net.socket.test.ts @@ -19,7 +19,7 @@ afterAll(() => { it('emulates socket connect event', async () => { interceptor.on('connection', ({ socket }) => { - socket.emit('connect') + socket.connect() }) const connectionListener = vi.fn() From 31fa254cf9207ea0aed3a1f4e5d232fa82e98ec2 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 Aug 2025 13:51:41 +0200 Subject: [PATCH 19/45] fix: pausing recorder, handling passthrough http --- src/interceptors/http/index.ts | 133 ++++++++++-------- src/interceptors/net/mock-socket.ts | 34 ++++- src/interceptors/net/socket-recorder.ts | 19 ++- test/modules/http/response/http-https.test.ts | 76 +++++----- 4 files changed, 159 insertions(+), 103 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 77287681a..351d89a3e 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -37,70 +37,85 @@ export class HttpRequestInterceptor extends Interceptor { }) socketInterceptor.on('connection', ({ options, socket }) => { - socket.once('write', (chunk, encoding) => { - const firstFrame = chunk.toString() + socket.runInternally(() => { + socket.once('write', (chunk, encoding) => { + 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] + if (!firstFrame.includes('HTTP/')) { + return + } - invariant( - method != null, - 'Failed to handle HTTP request: expected a valid HTTP method but got %s', - method, - options - ) + // 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] - const requestParser = createHttpRequestParserStream({ - requestOptions: { + invariant( + method != null, + 'Failed to handle HTTP request: expected a valid HTTP method but got %s', method, - ...options, - }, - onRequest: async (request) => { - const requestId = createRequestId() - const controller = new RequestController(request) - - const isRequestHandled = await handleRequest({ - request, - requestId, - controller, - emitter: this.emitter, - async onResponse(response) { - await respondWith({ - socket, - connectionOptions: options, - request, - response, - }) - }, - async onRequestError(response) { - await respondWith({ - socket, - connectionOptions: options, - request, - response, - }) - }, - onError(error) { - if (error instanceof Error) { - socket.destroy(error) - } - }, - }) - - if (!isRequestHandled) { - socket.passthrough() - } - }, + options + ) + + const requestParser = createHttpRequestParserStream({ + requestOptions: { + method, + ...options, + }, + onRequest: async (request) => { + const requestId = createRequestId() + const controller = new RequestController(request) + + const isRequestHandled = await handleRequest({ + request, + requestId, + controller, + emitter: this.emitter, + async onResponse(response) { + await respondWith({ + socket, + connectionOptions: options, + request, + response, + }) + }, + async onRequestError(response) { + await respondWith({ + socket, + connectionOptions: options, + request, + response, + }) + }, + onError(error) { + if (error instanceof Error) { + socket.destroy(error) + } + }, + }) + + if (!isRequestHandled) { + const passthroughSocket = socket.passthrough() + + /** + * @note Creating a passthroughsocket does NOT trigger the "socket" event + * from `http.ClientRequest` where the request, parser, and socket get + * associated. Recreate that association on the passthrough socket manually. + * @see https://github.com/nodejs/node/blob/134625d76139b4b3630d5baaf2efccae01ede564/lib/_http_client.js#L890 + */ + // @ts-expect-error Internal Node.js property. + passthroughSocket._httpMessage = socket._httpMessage + // @ts-expect-error Internal Node.js property.c + passthroughSocket.parser = socket.parser + // @ts-expect-error Internal Node.js property. + passthroughSocket.parser.socket = passthroughSocket + } + }, + }) + + // Write the header again because at this point it's already been written. + requestParser.write(toBuffer(chunk, encoding)) + socket.pipe(requestParser) }) - - // Write the header again because at this point it's already been written. - requestParser.write(toBuffer(chunk, encoding)) - socket.pipe(requestParser) }) }) } diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index f1bded411..3344d5dc9 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -34,7 +34,7 @@ export class MockSocket extends net.Socket { onEntry: (entry) => { if ( entry.type === 'apply' && - entry.metadata.property === 'passthrough' + ['runInternally', 'passthrough'].includes(entry.metadata.property) ) { return false } @@ -54,6 +54,7 @@ export class MockSocket extends net.Socket { } }, }) + return this[kRecorder].socket } @@ -76,12 +77,16 @@ export class MockSocket extends net.Socket { const [chunk, encoding, callback] = normalizeSocketWriteArgs( args as WriteArgs ) - this.emit('write', chunk, encoding, callback) + this.runInternally(() => { + this.emit('write', chunk, encoding, callback) + }) return true } public push(chunk: any, encoding?: BufferEncoding): boolean { - this.emit('push', chunk, encoding) + this.runInternally(() => { + this.emit('push', chunk, encoding) + }) return super.push(chunk, encoding) } @@ -89,18 +94,37 @@ export class MockSocket extends net.Socket { const [chunk, encoding, callback] = normalizeSocketWriteArgs( args as WriteArgs ) - this.emit('write', chunk, encoding, callback) + this.runInternally(() => { + this.emit('write', chunk, encoding, callback) + }) return super.end.apply(this, args) } + /** + * Invokes the given callback without its actions being recorded. + * Use this for internal logic that must not be replayed on the passthrough socket. + */ + public runInternally(callback: () => void) { + try { + this[kRecorder].pause() + callback() + } finally { + this[kRecorder].resume() + } + } + /** * Establishes the actual connection behind this socket. * Replays all the consumer interaction on the passthrough socket * and mirrors all the subsequent mock socket interactions onto the passthrough socket. */ - public passthrough(): void { + public passthrough(): net.Socket { const socket = this.options.createConnection() this[kRecorder].replay(socket) this[kPassthroughSocket] = socket + + socket.on('error', () => console.log('ERR!')) + + return socket } } diff --git a/src/interceptors/net/socket-recorder.ts b/src/interceptors/net/socket-recorder.ts index ec730eb44..4745354d3 100644 --- a/src/interceptors/net/socket-recorder.ts +++ b/src/interceptors/net/socket-recorder.ts @@ -5,6 +5,8 @@ const kSocketRecorder = Symbol('kSocketRecorder') export interface SocketRecorder { socket: T replay: (newSocket: net.Socket) => void + pause: () => void + resume: () => void } export interface SocketRecorderEntry { @@ -29,6 +31,7 @@ export function createSocketRecorder( ) => void } ): SocketRecorder { + let isRecording = true const entries: Array = [] Object.defineProperty(socket, kSocketRecorder, { @@ -38,6 +41,10 @@ export function createSocketRecorder( }) const addEntry = (entry: SocketRecorderEntry) => { + if (!isRecording) { + return + } + if (options?.onEntry?.(entry) !== false) { entries.push(entry) } @@ -61,7 +68,11 @@ export function createSocketRecorder( type: 'apply', metadata: { property }, replay(newSocket) { - fn.apply(newSocket, argArray) + Reflect.apply( + newSocket[property as keyof net.Socket] as Function, + newSocket, + argArray + ) }, }) } @@ -110,6 +121,12 @@ export function createSocketRecorder( } entries.length = 0 }, + pause() { + isRecording = false + }, + resume() { + isRecording = true + }, } } diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 074cb3f01..899bb3778 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -1,8 +1,8 @@ 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' const httpServer = new HttpServer((app) => { @@ -14,7 +14,8 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() + interceptor.on('request', ({ request, controller }) => { const url = new URL(request.url) @@ -37,7 +38,6 @@ interceptor.on('request', ({ request, controller }) => { beforeAll(async () => { await httpServer.listen() - interceptor.apply() }) @@ -47,8 +47,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 +57,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,49 +71,49 @@ 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) + 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'), { + 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') + const request = https.request('https://any.thing/non-existing') - req.end() - const { res, text } = await waitForClientRequest(req) + request.end() + const { res, text } = await waitForClientRequest(request) expect(res).toMatchObject>({ statusCode: 301, @@ -122,38 +122,38 @@ it('responds to a handled request issued by "https.request"', async () => { 'content-type': 'text/plain', }, }) - expect(await text()).toEqual('mocked') + await expect(text()).resolves.toEqual('mocked') }) 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 +161,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('/') }) From 72fdb4e8e15b3d0ac3cb1fb1f670f61162287bfd Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 Aug 2025 13:53:09 +0200 Subject: [PATCH 20/45] docs: update `socket.connect()` notice --- src/interceptors/net/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index df12a0e6b..5fd9daa70 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -75,9 +75,9 @@ export class SocketInterceptor extends Interceptor { /** * @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 it handy + * connect to the user. Calling `.connect()` on a mock socket is handy * for simulating a successful connection. Calling `.passthrough()` will - * tap into `net.connect()`, which calls `socket.connect()` immediately. + * tap into the unpatched `net.connect()`, which will call `socket.connect()`. */ this.emitter.emit('connection', { From 35203dcf2a6d76d38a3dbbd96c9fb75ca63665a1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 Aug 2025 15:01:21 +0200 Subject: [PATCH 21/45] test(readable-stream): migrate, response error case failing --- src/interceptors/http/index.ts | 36 +++++----- src/interceptors/net/index.ts | 6 +- src/interceptors/net/mock-socket.ts | 15 ++--- src/interceptors/net/socket-recorder.ts | 8 +-- .../http/response/http-response-delay.test.ts | 11 ++-- .../http/response/http-response-error.test.ts | 5 +- .../http-response-readable-stream.test.ts | 65 +++++++++---------- 7 files changed, 72 insertions(+), 74 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 351d89a3e..f5e935c51 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -13,7 +13,10 @@ import { RequestController } from '../../RequestController' import { handleRequest } from '../../utils/handleRequest' import { toBuffer } from './utils/to-buffer' import { getRawFetchHeaders } from '../ClientRequest/utils/recordRawHeaders' -import { isResponseError } from '../../utils/responseUtils' +import { + createServerErrorResponse, + isResponseError, +} from '../../utils/responseUtils' import { MockSocket } from '../Socket/MockSocket' /** @@ -116,6 +119,13 @@ export class HttpRequestInterceptor extends Interceptor { requestParser.write(toBuffer(chunk, encoding)) socket.pipe(requestParser) }) + + socket.on('push', (chunk, encoding) => { + /** + * @todo Route this through a response parser so both mocked + * and passthrough responses emit the "response" event for the user. + */ + }) }) }) } @@ -300,12 +310,12 @@ async function respondWith(args: { // Construct a regular server response to delegate body parsing to Node.js. const serverResponse = new ServerResponse(new IncomingMessage(socket)) - /** - * @note Provide a dummy socket to the server response to translate all its writes - * into pushes to the underlying mocked socket. This is only needed because we - * use `ServerResponse` to skip manual response message handling on our end. - */ serverResponse.assignSocket( + /** + * @note Provide a dummy socket 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 MockSocket({ write(chunk, encoding, callback) { socket.push(chunk, encoding) @@ -340,11 +350,6 @@ async function respondWith(args: { rawResponseHeaders ) - socket.once('error', (error) => { - // Destroy the mocked response if the developer destroys the socket. - serverResponse.destroy(error) - }) - if (response.body) { try { const reader = response.body.getReader() @@ -360,12 +365,11 @@ async function respondWith(args: { serverResponse.write(value) } } catch (error) { - /** - * Translate body stream errors to socket errors. - * @note We cannot use a mocked 500 response here because - * the response headers have already been written. - */ if (error instanceof Error) { + /** + * Destroy the socket if the response stream errored. + * @see https://github.com/mswjs/interceptors/issues/738 + */ socket.destroy(error) } } diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 5fd9daa70..af99d9bfd 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -2,8 +2,8 @@ import net from 'node:net' import { Interceptor } from '../../Interceptor' import { MockSocket } from './mock-socket' import { - NetConnectArgs, normalizeNetConnectArgs, + type NetConnectArgs, type NetworkConnectionOptions, } from './utils/normalize-net-connect-args' @@ -61,12 +61,12 @@ export class SocketInterceptor extends Interceptor { const originalConnect = Reflect.get(net.connect, kOriginalValue) Reflect.set(net.connect, kImplementation, (...args: Array) => { - const [options, connectionListener] = normalizeNetConnectArgs( + const [options, connectionCallback] = normalizeNetConnectArgs( args as NetConnectArgs ) const socket = new MockSocket({ ...args, - onConnect: connectionListener, + connectionCallback, createConnection() { return originalConnect(...args) }, diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 3344d5dc9..78c02ec9a 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -7,7 +7,7 @@ import { createSocketRecorder, type SocketRecorder } from './socket-recorder' interface MockSocketConstructorOptions extends net.SocketConstructorOpts { createConnection: () => net.Socket - onConnect?: () => void + connectionCallback?: () => void } const kRecorder = Symbol('kRecorder') @@ -28,6 +28,11 @@ export class MockSocket extends net.Socket { super(options) this.connecting = false + this.once('connect', () => { + this.connecting = false + this.options?.connectionCallback?.() + }) + this._final = (callback) => callback(null) this[kRecorder] = createSocketRecorder(this, { @@ -61,11 +66,6 @@ export class MockSocket extends net.Socket { public connect() { this.connecting = true - this.once('connect', () => { - this.connecting = false - this.options?.onConnect?.() - }) - queueMicrotask(() => { this.emit('connect') }) @@ -122,9 +122,6 @@ export class MockSocket extends net.Socket { const socket = this.options.createConnection() this[kRecorder].replay(socket) this[kPassthroughSocket] = socket - - socket.on('error', () => console.log('ERR!')) - return socket } } diff --git a/src/interceptors/net/socket-recorder.ts b/src/interceptors/net/socket-recorder.ts index 4745354d3..1f953e53b 100644 --- a/src/interceptors/net/socket-recorder.ts +++ b/src/interceptors/net/socket-recorder.ts @@ -31,7 +31,7 @@ export function createSocketRecorder( ) => void } ): SocketRecorder { - let isRecording = true + let isPaused = true const entries: Array = [] Object.defineProperty(socket, kSocketRecorder, { @@ -41,7 +41,7 @@ export function createSocketRecorder( }) const addEntry = (entry: SocketRecorderEntry) => { - if (!isRecording) { + if (isPaused) { return } @@ -122,10 +122,10 @@ export function createSocketRecorder( entries.length = 0 }, pause() { - isRecording = false + isPaused = true }, resume() { - isRecording = true + isPaused = false }, } } diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index 339c3d7c7..b6a42da8a 100644 --- a/test/modules/http/response/http-response-delay.test.ts +++ b/test/modules/http/response/http-response-delay.test.ts @@ -1,10 +1,11 @@ +// @vitest-environment node import { it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' +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 +35,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 +52,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 21fa65abe..ccb79bcb9 100644 --- a/test/modules/http/response/http-response-error.test.ts +++ b/test/modules/http/response/http-response-error.test.ts @@ -1,8 +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-readable-stream.test.ts b/test/modules/http/response/http-response-readable-stream.test.ts index 1a40db0a2..8b1718a77 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -1,19 +1,16 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { performance } from 'node:perf_hooks' import http from 'node:http' -import https from 'node:https' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { sleep, waitForClientRequest } from '../../../helpers' type ResponseChunks = Array<{ buffer: Buffer; timestamp: number }> const encoder = new TextEncoder() -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -28,8 +25,7 @@ afterAll(async () => { }) 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) { controller.enqueue(encoder.encode('hello')) @@ -41,13 +37,13 @@ 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) { controller.enqueue(encoder.encode('first')) @@ -74,7 +70,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 @@ -105,41 +101,40 @@ it('supports delays when enqueuing chunks', async () => { expect(chunkTimings[2] - chunkTimings[1]).toBeGreaterThanOrEqual(150) }) -it('forwards ReadableStream errors to the request', async () => { - const requestErrorListener = vi.fn() - const responseErrorListener = vi.fn() - - interceptor.once('request', ({ controller }) => { +it('destroys the socket on stream error if response headers have been sent', async () => { + interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ - start(controller) { + async start(controller) { controller.enqueue(new TextEncoder().encode('original')) - queueMicrotask(() => { - controller.error(new Error('stream error')) - }) + controller.error(new Error('stream error')) }, }) controller.respondWith(new Response(stream)) }) const request = http.get('http://localhost/resource') + + const requestErrorListener = vi.fn() request.on('error', requestErrorListener) - request.on('response', (response) => { - response.on('error', responseErrorListener) - }) - const response = await vi.waitFor(() => { - return new Promise((resolve) => { - request.on('response', resolve) + const requestCloseListener = vi.fn() + request.on('close', requestCloseListener) + + const responseError = await vi.waitFor(() => { + return new Promise((resolve) => { + request.on('response', (response) => { + console.log('RESPONSE!') + response.on('error', resolve) + }) }) }) - // Response stream errors are translated to unhandled exceptions, - // and then the server decides how to handle them. This is often - // done as returning a 500 response. - expect(response.statusCode).toBe(500) - expect(response.statusMessage).toBe('Unhandled Exception') + expect.soft(request.destroyed).toBe(true) + expect.soft(responseError).toBeInstanceOf(Error) + expect.soft(responseError.message).toBe('stream error') - // Response stream errors are not request errors. - expect(requestErrorListener).not.toHaveBeenCalled() - expect(request.destroyed).toBe(false) + expect + .soft(requestErrorListener, 'Request must not error') + .not.toHaveBeenCalled() + expect.soft(requestCloseListener, 'Request must close').toHaveBeenCalledOnce() }) From 24e21cd349fd1bb9ac8b619e41978fa3075fd544 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 Aug 2025 16:04:16 +0200 Subject: [PATCH 22/45] fix: request headers, `FetchResponse` order issue --- src/interceptors/http/index.ts | 27 +++++++++++-------- .../http-response-transfer-encoding.test.ts | 6 ++--- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index f5e935c51..a355405c4 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -3,20 +3,21 @@ import { Readable, Writable } from 'node:stream' import { invariant } from 'outvariant' import { Interceptor, INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' import { type HttpRequestEventMap } from '../../glossary' -import { FetchResponse } from '../../utils/fetchUtils' import { SocketInterceptor } from '../net' +import { FetchResponse } from '../../utils/fetchUtils' import { HttpRequestParser } from './http-parser' import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' 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 { handleRequest } from '../../utils/handleRequest' -import { toBuffer } from './utils/to-buffer' -import { getRawFetchHeaders } from '../ClientRequest/utils/recordRawHeaders' import { - createServerErrorResponse, - isResponseError, -} from '../../utils/responseUtils' + getRawFetchHeaders, + recordRawFetchHeaders, + restoreHeadersPrototype, +} from '../ClientRequest/utils/recordRawHeaders' +import { isResponseError } from '../../utils/responseUtils' import { MockSocket } from '../Socket/MockSocket' /** @@ -39,6 +40,11 @@ export class HttpRequestInterceptor extends Interceptor { socketInterceptor.dispose() }) + recordRawFetchHeaders() + this.subscriptions.push(() => { + restoreHeadersPrototype() + }) + socketInterceptor.on('connection', ({ options, socket }) => { socket.runInternally(() => { socket.once('write', (chunk, encoding) => { @@ -159,11 +165,10 @@ function createHttpRequestParserStream(options: { const baseUrl = baseUrlFromConnectionOptions(options.requestOptions) const url = new URL(path || '', baseUrl) - const headers = new Headers() - // const headers = FetchResponse.parseRawHeaders([ - // ...requestRawHeadersBuffer, - // ...(rawHeaders || []), - // ]) + const headers = FetchResponse.parseRawHeaders([ + ...requestRawHeadersBuffer, + ...(rawHeaders || []), + ]) const canHaveBody = method !== 'GET' && method !== 'HEAD' 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..a0ecef93d 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,10 @@ // @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 +18,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', { From ed9c6bcdcd0a454bb98a4eb15bd3877882f9756a Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 Aug 2025 16:07:36 +0200 Subject: [PATCH 23/45] test: migrate more tests to `http` --- test/modules/http/intercept/http.get.test.ts | 56 +++++++++++-------- .../{net.http.test.ts => net-http.test.ts} | 0 ...{net.socket.test.ts => net-socket.test.ts} | 0 ...{net.undici.test.ts => net-undici.test.ts} | 0 4 files changed, 33 insertions(+), 23 deletions(-) rename test/modules/net/{net.http.test.ts => net-http.test.ts} (100%) rename test/modules/net/{net.socket.test.ts => net-socket.test.ts} (100%) rename test/modules/net/{net.undici.test.ts => net-undici.test.ts} (100%) diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index 609c4b90c..5ea5f6d46 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -1,7 +1,8 @@ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' +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 +13,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 +21,7 @@ beforeAll(async () => { }) afterEach(() => { + interceptor.removeAllListeners() vi.resetAllMocks() }) @@ -32,6 +31,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 +44,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 +60,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/net/net.http.test.ts b/test/modules/net/net-http.test.ts similarity index 100% rename from test/modules/net/net.http.test.ts rename to test/modules/net/net-http.test.ts diff --git a/test/modules/net/net.socket.test.ts b/test/modules/net/net-socket.test.ts similarity index 100% rename from test/modules/net/net.socket.test.ts rename to test/modules/net/net-socket.test.ts diff --git a/test/modules/net/net.undici.test.ts b/test/modules/net/net-undici.test.ts similarity index 100% rename from test/modules/net/net.undici.test.ts rename to test/modules/net/net-undici.test.ts From dd9855ea1cff69ceb234e22fafb8a2e2450aa223 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 Aug 2025 16:18:38 +0200 Subject: [PATCH 24/45] test: migrate the rest `http` tests --- test/modules/http/compliance/events.test.ts | 8 +++--- .../http/compliance/http-custom-agent.test.ts | 4 +-- .../http/compliance/http-errors.test.ts | 10 +++----- .../compliance/http-event-connect.test.ts | 8 +++--- .../http-head-response-body.test.ts | 8 +++--- .../http-max-header-fields-count.test.ts | 10 ++++---- .../compliance/http-modify-request.test.ts | 8 +++--- .../http/compliance/http-rate-limit.test.ts | 4 +-- .../http/compliance/http-req-callback.test.ts | 8 +++--- .../compliance/http-req-get-with-body.test.ts | 4 +-- .../http/compliance/http-req-method.test.ts | 8 +++--- .../http-req-url-to-http-options.test.ts | 4 +-- .../http/compliance/http-req-write.test.ts | 10 +++----- .../http/compliance/http-request-ipv6.test.ts | 5 ++-- .../http-request-without-options.test.ts | 8 +++--- .../http/compliance/http-res-destroy.test.ts | 4 +-- .../http-res-non-configurable.test.ts | 6 ++--- .../compliance/http-res-raw-headers.test.ts | 8 +++--- .../http-res-read-multiple-times.test.ts | 4 +-- .../compliance/http-res-set-encoding.test.ts | 8 +++--- .../http-response-headers-folding.test.ts | 4 +-- .../http/compliance/http-signal.test.ts | 8 +++--- .../http/compliance/http-ssl-socket.test.ts | 8 +++--- .../http/compliance/http-timeout.test.ts | 8 +++--- .../http-unhandled-exception.test.ts | 8 +++--- .../http/compliance/http-unix-socket.test.ts | 20 +++++++-------- .../http/compliance/http-upgrade.test.ts | 4 +-- test/modules/http/compliance/http.test.ts | 4 +-- .../http/compliance/https-constructor.test.ts | 4 +-- .../compliance/https-custom-agent.test.ts | 6 ++--- test/modules/http/compliance/https.test.ts | 8 +++--- test/modules/http/http-performance.test.ts | 4 +-- .../http-client-request-agent.test.ts | 4 +-- .../intercept/http-client-request.test.ts | 5 ++-- .../http/intercept/http.request.test.ts | 7 +++--- test/modules/http/intercept/https.get.test.ts | 7 +++--- .../http/intercept/https.request.test.ts | 7 +++--- ...ncurrent-different-response-source.test.ts | 19 ++++++-------- .../http-concurrent-same-host.test.ts | 25 ++++++++++--------- ...ttp-empty-readable-stream-response.test.ts | 12 ++++----- ...ttp-max-listeners-exceeded-warning.test.ts | 4 +-- .../http-post-missing-first-bytes.test.ts | 4 +-- .../regressions/http-socket-timeout.test.ts | 4 +-- .../http/regressions/http-socket-timeout.ts | 8 +++--- .../http-await-response-event.test.ts | 9 ++++--- .../response/http-response-patching.test.ts | 5 ++-- 46 files changed, 156 insertions(+), 187 deletions(-) diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts index 1f82d2e8e..224cbaae2 100644 --- a/test/modules/http/compliance/events.test.ts +++ b/test/modules/http/compliance/events.test.ts @@ -1,10 +1,8 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { vi, 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/index' import { HttpRequestEventMap } from '../../../../src/glossary' import { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' @@ -14,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-custom-agent.test.ts b/test/modules/http/compliance/http-custom-agent.test.ts index d902b17a1..efae75523 100644 --- a/test/modules/http/compliance/http-custom-agent.test.ts +++ b/test/modules/http/compliance/http-custom-agent.test.ts @@ -1,12 +1,12 @@ // @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 e0d016a44..c8132c5e0 100644 --- a/test/modules/http/compliance/http-errors.test.ts +++ b/test/modules/http/compliance/http-errors.test.ts @@ -1,13 +1,11 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { vi, it, expect, beforeAll, afterAll } from 'vitest' -import http from 'http' +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..c81ccffa1 100644 --- a/test/modules/http/compliance/http-event-connect.test.ts +++ b/test/modules/http/compliance/http-event-connect.test.ts @@ -1,11 +1,9 @@ -/** - * @vitest-environment node - */ +// @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/index' import { waitForClientRequest } from '../../../../test/helpers' const httpServer = new HttpServer((app) => { @@ -14,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-head-response-body.test.ts b/test/modules/http/compliance/http-head-response-body.test.ts index 4ea5e3e7f..ec0da24cc 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,10 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, 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-max-header-fields-count.test.ts b/test/modules/http/compliance/http-max-header-fields-count.test.ts index 767743327..b7a25a822 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,11 @@ // @vitest-environment node -import http from 'node:http' import { afterAll, afterEach, beforeAll, it, expect } from 'vitest' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import http from 'node:http' import { waitForClientRequest } from '../../../helpers' import { DeferredPromise } from '@open-draft/deferred-promise' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() @@ -100,9 +100,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..e053d29e4 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -1,10 +1,8 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, afterEach, 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 server = new HttpServer((app) => { @@ -13,7 +11,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..6a4e83b2e 100644 --- a/test/modules/http/compliance/http-rate-limit.test.ts +++ b/test/modules/http/compliance/http-rate-limit.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 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 +27,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..2f484d910 100644 --- a/test/modules/http/compliance/http-req-callback.test.ts +++ b/test/modules/http/compliance/http-req-callback.test.ts @@ -1,12 +1,10 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +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 +12,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..3992272fa 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 @@ -2,12 +2,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..e6ca87c3c 100644 --- a/test/modules/http/compliance/http-req-method.test.ts +++ b/test/modules/http/compliance/http-req-method.test.ts @@ -1,12 +1,10 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node 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' 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..9e1fd39dc 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,11 @@ // @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..ffa0b278b 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -1,12 +1,10 @@ -/** - * @vitest-environment node - */ -import { Readable } from 'node:stream' +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import { Readable } from 'node:stream' 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 +15,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..be0fa7b74 100644 --- a/test/modules/http/compliance/http-request-ipv6.test.ts +++ b/test/modules/http/compliance/http-request-ipv6.test.ts @@ -1,9 +1,10 @@ +// @vitest-environment node import { it, expect, beforeAll, afterAll } from 'vitest' +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 16fa4ece5..a7543e09b 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,10 @@ -/** - * @vitest-environment node - */ +// @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() 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..d401cc8bb 100644 --- a/test/modules/http/compliance/http-res-destroy.test.ts +++ b/test/modules/http/compliance/http-res-destroy.test.ts @@ -1,7 +1,7 @@ // @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 +9,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..322ff2a5e 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,15 @@ /** * @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..3aed24e1c 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,8 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, afterEach, 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' // The actual server is here for A/B purpose only. @@ -15,7 +13,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..ddf44c0cc 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 @@ -5,10 +5,10 @@ * @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 +18,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..c92dc9ccf 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,9 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, describe, 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', (request, res) => { @@ -13,7 +11,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..aed91ee34 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,10 @@ // @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..eab13c2be 100644 --- a/test/modules/http/compliance/http-signal.test.ts +++ b/test/modules/http/compliance/http-signal.test.ts @@ -1,11 +1,9 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +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 +13,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 3966c69c8..64ced641c 100644 --- a/test/modules/http/compliance/http-ssl-socket.test.ts +++ b/test/modules/http/compliance/http-ssl-socket.test.ts @@ -1,13 +1,11 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import https from 'node:https' import type { 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() diff --git a/test/modules/http/compliance/http-timeout.test.ts b/test/modules/http/compliance/http-timeout.test.ts index bcd2428b6..4b9a56b97 100644 --- a/test/modules/http/compliance/http-timeout.test.ts +++ b/test/modules/http/compliance/http-timeout.test.ts @@ -1,11 +1,9 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' +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 +13,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 9dc75a55b..26ce6c193 100644 --- a/test/modules/http/compliance/http-unhandled-exception.test.ts +++ b/test/modules/http/compliance/http-unhandled-exception.test.ts @@ -1,12 +1,10 @@ -/** - * @vitest-environment node - */ +// @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 f7c71036a..949477e23 100644 --- a/test/modules/http/compliance/http-unix-socket.test.ts +++ b/test/modules/http/compliance/http-unix-socket.test.ts @@ -1,15 +1,13 @@ -/** - * @vitest-environment node - */ -import http from 'node:http' -import { tmpdir } from 'node:os'; +// @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' +import http from 'node:http' +import { tmpdir } from 'node:os' import { DeferredPromise } from '@open-draft/deferred-promise' -import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' import { waitForClientRequest } from '../../../helpers' -import exp from 'node:constants'; +import exp from 'node:constants' -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() const socketPath = tmpdir() + '/socket.sock' const httpServer = new http.Server((req, res) => { @@ -64,6 +62,8 @@ describe('Unix socket', () => { const { text } = await waitForClientRequest(request) expect(await text()).toBe('hello world') - await expect(requestListenerPromise).resolves.toStrictEqual('http://localhost/test-get') + await expect(requestListenerPromise).resolves.toStrictEqual( + 'http://localhost/test-get' + ) }) -}) \ No newline at end of file +}) diff --git a/test/modules/http/compliance/http-upgrade.test.ts b/test/modules/http/compliance/http-upgrade.test.ts index ea286ae36..5b0772eb3 100644 --- a/test/modules/http/compliance/http-upgrade.test.ts +++ b/test/modules/http/compliance/http-upgrade.test.ts @@ -5,9 +5,9 @@ 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..97f74b3c5 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -1,13 +1,13 @@ // @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..86f18273c 100644 --- a/test/modules/http/compliance/https-constructor.test.ts +++ b/test/modules/http/compliance/https-constructor.test.ts @@ -3,20 +3,20 @@ * @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..48402ef23 100644 --- a/test/modules/http/compliance/https-custom-agent.test.ts +++ b/test/modules/http/compliance/https-custom-agent.test.ts @@ -1,9 +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 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 +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/https.test.ts b/test/modules/http/compliance/https.test.ts index 2d392c454..88acfb083 100644 --- a/test/modules/http/compliance/https.test.ts +++ b/test/modules/http/compliance/https.test.ts @@ -1,10 +1,8 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { vi, it, expect, beforeAll, afterAll } from 'vitest' +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 +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/http-performance.test.ts b/test/modules/http/http-performance.test.ts index a22cff9c4..48d67b1fc 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -3,8 +3,8 @@ */ import { it, expect, beforeAll, afterAll } from 'vitest' 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 +31,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 index a0e1f3b68..a5bb88667 100644 --- a/test/modules/http/intercept/http-client-request-agent.test.ts +++ b/test/modules/http/intercept/http-client-request-agent.test.ts @@ -4,17 +4,17 @@ * does not result in duplicate mock HTTP sockets/agents being created. */ import { beforeAll, afterEach, afterAll, it, expect } from 'vitest' +import { HttpRequestInterceptor } from '../../../../src/interceptors/http' 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() +const interceptor = new HttpRequestInterceptor() beforeAll(() => { interceptor.apply() diff --git a/test/modules/http/intercept/http-client-request.test.ts b/test/modules/http/intercept/http-client-request.test.ts index 5b395601f..b07d06425 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 @@ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, 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 { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' import { RequestController } from '../../../../src/RequestController' import { HttpRequestEventMap } from '../../../../src/glossary' @@ -12,7 +13,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 8c75a41aa..79ad78de1 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -1,11 +1,12 @@ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import http from 'http' +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) => { @@ -19,7 +20,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.get.test.ts b/test/modules/http/intercept/https.get.test.ts index 3c420d024..367c8018e 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -1,10 +1,11 @@ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import https from 'https' +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 +14,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..bb63d2d7b 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -1,11 +1,12 @@ +// @vitest-environment node import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' -import https from 'https' +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) => { @@ -21,7 +22,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/regressions/http-concurrent-different-response-source.test.ts b/test/modules/http/regressions/http-concurrent-different-response-source.test.ts index c0b34c8bb..4b930350d 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,8 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' 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 +11,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -50,9 +47,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..687bc232d 100644 --- a/test/modules/http/regressions/http-concurrent-same-host.test.ts +++ b/test/modules/http/regressions/http-concurrent-same-host.test.ts @@ -3,12 +3,13 @@ * @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 +43,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..70402987f 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,10 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, 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() @@ -17,7 +15,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 +29,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..639fab296 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 @@ -3,9 +3,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 +18,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..0a8a68f13 100644 --- a/test/modules/http/regressions/http-socket-timeout.test.ts +++ b/test/modules/http/regressions/http-socket-timeout.test.ts @@ -1,6 +1,4 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import { it, expect, beforeAll, afterAll } from 'vitest' import { ChildProcess, spawn } from 'child_process' 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..6440c82c3 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,8 @@ +// @vitest-environment node import { vi, it, expect, beforeAll, afterAll, afterEach } 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) => { @@ -10,7 +11,7 @@ const httpServer = new HttpServer((app) => { }) }) -const interceptor = new ClientRequestInterceptor() +const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() @@ -44,7 +45,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 +67,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-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 613cfd332..85047c082 100644 --- a/test/modules/http/response/http-response-patching.test.ts +++ b/test/modules/http/response/http-response-patching.test.ts @@ -1,7 +1,8 @@ +// @vitest-environment node 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 { sleep, waitForClientRequest } from '../../../helpers' const server = new HttpServer((app) => { @@ -10,7 +11,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) From e262b9889744228723a7cbcb7a8a494aa25ac7da Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 Aug 2025 16:21:44 +0200 Subject: [PATCH 25/45] chore: use `vitest/globals` --- test/modules/http/compliance/events.test.ts | 1 - test/modules/http/compliance/http-custom-agent.test.ts | 1 - test/modules/http/compliance/http-errors.test.ts | 1 - test/modules/http/compliance/http-event-connect.test.ts | 1 - test/modules/http/compliance/http-head-response-body.test.ts | 1 - .../http/compliance/http-max-header-fields-count.test.ts | 1 - test/modules/http/compliance/http-modify-request.test.ts | 1 - test/modules/http/compliance/http-rate-limit.test.ts | 1 - test/modules/http/compliance/http-req-callback.test.ts | 1 - test/modules/http/compliance/http-req-get-with-body.test.ts | 1 - test/modules/http/compliance/http-req-method.test.ts | 1 - .../http/compliance/http-req-url-to-http-options.test.ts | 1 - test/modules/http/compliance/http-req-write.test.ts | 1 - test/modules/http/compliance/http-request-ipv6.test.ts | 1 - .../http/compliance/http-request-without-options.test.ts | 1 - test/modules/http/compliance/http-res-destroy.test.ts | 1 - .../http/compliance/http-res-non-configurable.test.ts | 1 - test/modules/http/compliance/http-res-raw-headers.test.ts | 1 - .../http/compliance/http-res-read-multiple-times.test.ts | 1 - test/modules/http/compliance/http-res-set-encoding.test.ts | 1 - .../http/compliance/http-response-headers-folding.test.ts | 1 - test/modules/http/compliance/http-signal.test.ts | 1 - test/modules/http/compliance/http-ssl-socket.test.ts | 1 - test/modules/http/compliance/http-timeout.test.ts | 1 - .../modules/http/compliance/http-unhandled-exception.test.ts | 1 - test/modules/http/compliance/http-unix-socket.test.ts | 1 - test/modules/http/compliance/http-upgrade.test.ts | 1 - test/modules/http/compliance/http.test.ts | 1 - test/modules/http/compliance/https-constructor.test.ts | 1 - test/modules/http/compliance/https-custom-agent.test.ts | 1 - test/modules/http/compliance/https.test.ts | 1 - test/modules/http/http-performance.test.ts | 5 +---- .../modules/http/intercept/http-client-request-agent.test.ts | 1 - test/modules/http/intercept/http-client-request.test.ts | 1 - test/modules/http/intercept/http.get.test.ts | 1 - test/modules/http/intercept/http.request.test.ts | 1 - test/modules/http/intercept/https.get.test.ts | 1 - test/modules/http/intercept/https.request.test.ts | 1 - .../http-concurrent-different-response-source.test.ts | 1 - .../http/regressions/http-concurrent-same-host.test.ts | 1 - .../regressions/http-empty-readable-stream-response.test.ts | 1 - .../regressions/http-max-listeners-exceeded-warning.test.ts | 1 - test/modules/http/regressions/http-socket-timeout.test.ts | 1 - test/modules/http/response/http-await-response-event.test.ts | 1 - test/modules/http/response/http-empty-response.test.ts | 1 - test/modules/http/response/http-https.test.ts | 1 - test/modules/http/response/http-response-delay.test.ts | 1 - test/modules/http/response/http-response-error.test.ts | 1 - test/modules/http/response/http-response-patching.test.ts | 1 - .../http/response/http-response-readable-stream.test.ts | 1 - .../http/response/http-response-transfer-encoding.test.ts | 1 - test/tsconfig.json | 3 ++- test/vitest.config.js | 1 + 53 files changed, 4 insertions(+), 55 deletions(-) diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts index 224cbaae2..beb1952d6 100644 --- a/test/modules/http/compliance/events.test.ts +++ b/test/modules/http/compliance/events.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, 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' diff --git a/test/modules/http/compliance/http-custom-agent.test.ts b/test/modules/http/compliance/http-custom-agent.test.ts index efae75523..124b80ab5 100644 --- a/test/modules/http/compliance/http-custom-agent.test.ts +++ b/test/modules/http/compliance/http-custom-agent.test.ts @@ -1,5 +1,4 @@ // @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' diff --git a/test/modules/http/compliance/http-errors.test.ts b/test/modules/http/compliance/http-errors.test.ts index c8132c5e0..cde0b594c 100644 --- a/test/modules/http/compliance/http-errors.test.ts +++ b/test/modules/http/compliance/http-errors.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { DeferredPromise } from '@open-draft/deferred-promise' diff --git a/test/modules/http/compliance/http-event-connect.test.ts b/test/modules/http/compliance/http-event-connect.test.ts index c81ccffa1..48d06ff3b 100644 --- a/test/modules/http/compliance/http-event-connect.test.ts +++ b/test/modules/http/compliance/http-event-connect.test.ts @@ -1,5 +1,4 @@ // @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' 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 ec0da24cc..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,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { waitForClientRequest } from '../../../helpers' 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 b7a25a822..8f0d06a80 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,5 +1,4 @@ // @vitest-environment node -import { afterAll, afterEach, beforeAll, it, expect } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { waitForClientRequest } from '../../../helpers' diff --git a/test/modules/http/compliance/http-modify-request.test.ts b/test/modules/http/compliance/http-modify-request.test.ts index e053d29e4..343aba5e0 100644 --- a/test/modules/http/compliance/http-modify-request.test.ts +++ b/test/modules/http/compliance/http-modify-request.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/compliance/http-rate-limit.test.ts b/test/modules/http/compliance/http-rate-limit.test.ts index 6a4e83b2e..4f5b57e91 100644 --- a/test/modules/http/compliance/http-rate-limit.test.ts +++ b/test/modules/http/compliance/http-rate-limit.test.ts @@ -1,5 +1,4 @@ // @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' diff --git a/test/modules/http/compliance/http-req-callback.test.ts b/test/modules/http/compliance/http-req-callback.test.ts index 2f484d910..87bce9e85 100644 --- a/test/modules/http/compliance/http-req-callback.test.ts +++ b/test/modules/http/compliance/http-req-callback.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { IncomingMessage } from 'node:http' import https from 'node:https' 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 3992272fa..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,7 +1,6 @@ /** * @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' diff --git a/test/modules/http/compliance/http-req-method.test.ts b/test/modules/http/compliance/http-req-method.test.ts index e6ca87c3c..419bf7109 100644 --- a/test/modules/http/compliance/http-req-method.test.ts +++ b/test/modules/http/compliance/http-req-method.test.ts @@ -1,5 +1,4 @@ // @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' 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 9e1fd39dc..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 @@ -2,7 +2,6 @@ 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 { waitForClientRequest } from '../../../../test/helpers' const interceptor = new HttpRequestInterceptor() diff --git a/test/modules/http/compliance/http-req-write.test.ts b/test/modules/http/compliance/http-req-write.test.ts index ffa0b278b..f7e1cc2b6 100644 --- a/test/modules/http/compliance/http-req-write.test.ts +++ b/test/modules/http/compliance/http-req-write.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { Readable } from 'node:stream' import http from 'node:http' diff --git a/test/modules/http/compliance/http-request-ipv6.test.ts b/test/modules/http/compliance/http-request-ipv6.test.ts index be0fa7b74..fad0d8763 100644 --- a/test/modules/http/compliance/http-request-ipv6.test.ts +++ b/test/modules/http/compliance/http-request-ipv6.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { httpGet } from '../../../helpers' import { DeferredPromise } from '@open-draft/deferred-promise' 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 a7543e09b..1b7f0e9d0 100644 --- a/test/modules/http/compliance/http-request-without-options.test.ts +++ b/test/modules/http/compliance/http-request-without-options.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { waitForClientRequest } from '../../../helpers' diff --git a/test/modules/http/compliance/http-res-destroy.test.ts b/test/modules/http/compliance/http-res-destroy.test.ts index d401cc8bb..0dc0210a1 100644 --- a/test/modules/http/compliance/http-res-destroy.test.ts +++ b/test/modules/http/compliance/http-res-destroy.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/lib/http' 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 322ff2a5e..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,7 +2,6 @@ /** * @see https://github.com/mswjs/msw/issues/2307 */ -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' 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 3aed24e1c..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,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' 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 ddf44c0cc..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,7 +4,6 @@ * 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' 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 c92dc9ccf..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,5 +1,4 @@ // @vitest-environment node -import { it, expect, describe, beforeAll, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http, { IncomingMessage } from 'node:http' import { HttpServer } from '@open-draft/test-server/http' 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 aed91ee34..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,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { waitForClientRequest } from '../../../helpers' diff --git a/test/modules/http/compliance/http-signal.test.ts b/test/modules/http/compliance/http-signal.test.ts index eab13c2be..44cf0d2f7 100644 --- a/test/modules/http/compliance/http-signal.test.ts +++ b/test/modules/http/compliance/http-signal.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/compliance/http-ssl-socket.test.ts b/test/modules/http/compliance/http-ssl-socket.test.ts index 64ced641c..dcb01f17f 100644 --- a/test/modules/http/compliance/http-ssl-socket.test.ts +++ b/test/modules/http/compliance/http-ssl-socket.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import https from 'node:https' import type { TLSSocket } from 'node:tls' diff --git a/test/modules/http/compliance/http-timeout.test.ts b/test/modules/http/compliance/http-timeout.test.ts index 4b9a56b97..838ab545d 100644 --- a/test/modules/http/compliance/http-timeout.test.ts +++ b/test/modules/http/compliance/http-timeout.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/compliance/http-unhandled-exception.test.ts b/test/modules/http/compliance/http-unhandled-exception.test.ts index 26ce6c193..f1d7d0d67 100644 --- a/test/modules/http/compliance/http-unhandled-exception.test.ts +++ b/test/modules/http/compliance/http-unhandled-exception.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { waitForClientRequest } from '../../../helpers' diff --git a/test/modules/http/compliance/http-unix-socket.test.ts b/test/modules/http/compliance/http-unix-socket.test.ts index 949477e23..b48dfe649 100644 --- a/test/modules/http/compliance/http-unix-socket.test.ts +++ b/test/modules/http/compliance/http-unix-socket.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { tmpdir } from 'node:os' diff --git a/test/modules/http/compliance/http-upgrade.test.ts b/test/modules/http/compliance/http-upgrade.test.ts index 5b0772eb3..7b1efd446 100644 --- a/test/modules/http/compliance/http-upgrade.test.ts +++ b/test/modules/http/compliance/http-upgrade.test.ts @@ -2,7 +2,6 @@ * @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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' diff --git a/test/modules/http/compliance/http.test.ts b/test/modules/http/compliance/http.test.ts index 97f74b3c5..7ff5633f7 100644 --- a/test/modules/http/compliance/http.test.ts +++ b/test/modules/http/compliance/http.test.ts @@ -1,5 +1,4 @@ // @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' diff --git a/test/modules/http/compliance/https-constructor.test.ts b/test/modules/http/compliance/https-constructor.test.ts index 86f18273c..d83b8b59b 100644 --- a/test/modules/http/compliance/https-constructor.test.ts +++ b/test/modules/http/compliance/https-constructor.test.ts @@ -2,7 +2,6 @@ * @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' diff --git a/test/modules/http/compliance/https-custom-agent.test.ts b/test/modules/http/compliance/https-custom-agent.test.ts index 48402ef23..2ddc5f06e 100644 --- a/test/modules/http/compliance/https-custom-agent.test.ts +++ b/test/modules/http/compliance/https-custom-agent.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import https from 'node:https' diff --git a/test/modules/http/compliance/https.test.ts b/test/modules/http/compliance/https.test.ts index 88acfb083..54037061c 100644 --- a/test/modules/http/compliance/https.test.ts +++ b/test/modules/http/compliance/https.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/http-performance.test.ts b/test/modules/http/http-performance.test.ts index 48d67b1fc..9c4166f44 100644 --- a/test/modules/http/http-performance.test.ts +++ b/test/modules/http/http-performance.test.ts @@ -1,7 +1,4 @@ -/** - * @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' diff --git a/test/modules/http/intercept/http-client-request-agent.test.ts b/test/modules/http/intercept/http-client-request-agent.test.ts index a5bb88667..b0a14e5ec 100644 --- a/test/modules/http/intercept/http-client-request-agent.test.ts +++ b/test/modules/http/intercept/http-client-request-agent.test.ts @@ -3,7 +3,6 @@ * 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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import https from 'node:https' diff --git a/test/modules/http/intercept/http-client-request.test.ts b/test/modules/http/intercept/http-client-request.test.ts index b07d06425..74701b1ba 100644 --- a/test/modules/http/intercept/http-client-request.test.ts +++ b/test/modules/http/intercept/http-client-request.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/intercept/http.get.test.ts b/test/modules/http/intercept/http.get.test.ts index 5ea5f6d46..82c976455 100644 --- a/test/modules/http/intercept/http.get.test.ts +++ b/test/modules/http/intercept/http.get.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 79ad78de1..2888ab604 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/intercept/https.get.test.ts b/test/modules/http/intercept/https.get.test.ts index 367c8018e..1085372d9 100644 --- a/test/modules/http/intercept/https.get.test.ts +++ b/test/modules/http/intercept/https.get.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import https from 'node:https' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index bb63d2d7b..30018c765 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import https from 'node:https' import { RequestHandler } from 'express' 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 4b930350d..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,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpServer } from '@open-draft/test-server/http' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { httpGet, sleep } from '../../../helpers' 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 687bc232d..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,7 +2,6 @@ * @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' 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 70402987f..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,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { waitForClientRequest } from '../../../helpers' 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 639fab296..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,7 +2,6 @@ /** * @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' diff --git a/test/modules/http/regressions/http-socket-timeout.test.ts b/test/modules/http/regressions/http-socket-timeout.test.ts index 0a8a68f13..7f0199f3f 100644 --- a/test/modules/http/regressions/http-socket-timeout.test.ts +++ b/test/modules/http/regressions/http-socket-timeout.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll } from 'vitest' import { ChildProcess, spawn } from 'child_process' let child: ChildProcess 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 6440c82c3..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,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterAll, afterEach } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { HttpServer } from '@open-draft/test-server/http' diff --git a/test/modules/http/response/http-empty-response.test.ts b/test/modules/http/response/http-empty-response.test.ts index ad15ac7ac..f04149d59 100644 --- a/test/modules/http/response/http-empty-response.test.ts +++ b/test/modules/http/response/http-empty-response.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { it, expect, beforeAll, afterAll, afterEach } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import { waitForClientRequest } from '../../../helpers' diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 899bb3778..01df51a17 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -1,4 +1,3 @@ -import { it, expect, beforeAll, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' import https from 'node:https' diff --git a/test/modules/http/response/http-response-delay.test.ts b/test/modules/http/response/http-response-delay.test.ts index b6a42da8a..8c219e56e 100644 --- a/test/modules/http/response/http-response-delay.test.ts +++ b/test/modules/http/response/http-response-delay.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -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' diff --git a/test/modules/http/response/http-response-error.test.ts b/test/modules/http/response/http-response-error.test.ts index ccb79bcb9..2a5ef2d43 100644 --- a/test/modules/http/response/http-response-error.test.ts +++ b/test/modules/http/response/http-response-error.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import http from 'node:http' diff --git a/test/modules/http/response/http-response-patching.test.ts b/test/modules/http/response/http-response-patching.test.ts index 85047c082..4baf998c6 100644 --- a/test/modules/http/response/http-response-patching.test.ts +++ b/test/modules/http/response/http-response-patching.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -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' 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 8b1718a77..d23ce9713 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -1,5 +1,4 @@ // @vitest-environment node -import { vi, it, expect, beforeAll, afterEach, afterAll } from 'vitest' import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { performance } from 'node:perf_hooks' import http from 'node:http' 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 a0ecef93d..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,5 +1,4 @@ // @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' 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: { From eea97bd377004c9aa1adb4646614b917e965c56e Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 Aug 2025 16:38:41 +0200 Subject: [PATCH 26/45] test: add unit tests for `normalizeNetConnectArgs` --- .../utils/normalize-net-connect-args.test.ts | 153 ++++++++++++++++++ .../net/utils/normalize-net-connect-args.ts | 24 ++- tsconfig.json | 4 +- vitest.config.mjs | 1 + 4 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 src/interceptors/net/utils/normalize-net-connect-args.test.ts 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 index 5b687473b..ac57ecd7a 100644 --- a/src/interceptors/net/utils/normalize-net-connect-args.ts +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -18,15 +18,25 @@ export type NetConnectArgs = | [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 -): [options: NetworkConnectionOptions, connectionListener?: () => void] { +): NormalizedNetConnectArgs { + const callback = typeof args[1] === 'function' ? args[1] : args[2] + if (typeof args[0] === 'string') { - return [{ path: args[0] }, args[1]] + return [{ path: args[0] }, callback] } if (typeof args[0] === 'number' && typeof args[1] === 'string') { - return [{ port: args[0], path: '', host: args[1] }, args[2]] + return [{ port: args[0], path: '', host: args[1] }, callback] } if (typeof args[0] === 'object') { @@ -35,10 +45,10 @@ export function normalizeNetConnectArgs( { path: args[0].pathname || '', port: +args[0].port, - host: args[0].host, + host: args[0].hostname, protocol: args[0].protocol, }, - args[1], + callback, ] } @@ -54,7 +64,7 @@ export function normalizeNetConnectArgs( localAddress: args[0].localAddress, localPort: args[0].localPort, }, - args[1], + callback, ] } @@ -65,7 +75,7 @@ export function normalizeNetConnectArgs( session: Reflect.get(args[0], 'session'), auth: Reflect.get(args[0], 'auth'), }, - args[1], + callback, ] } diff --git a/tsconfig.json b/tsconfig.json index a653125f7..2bd2485ee 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,7 @@ "esModuleInterop": true, "downlevelIteration": true, "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator"], - "types": ["@types/node"], + "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: { From 674b8aa161ac07fd9f8f07070d775fa851e75874 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 Aug 2025 18:19:35 +0200 Subject: [PATCH 27/45] test(readable-stream): add real server --- .../http-response-readable-stream.test.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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 d23ce9713..d75e3a8d7 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -2,17 +2,33 @@ import { HttpRequestInterceptor } from '../../../../src/interceptors/http' import { performance } from 'node:perf_hooks' import http from 'node:http' +import { Readable } from 'node:stream' import { DeferredPromise } from '@open-draft/deferred-promise' +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 httpServer = new HttpServer((app) => { + app.get('/', (req, res) => { + res.writeHead(200) + Readable.fromWeb( + new ReadableStream({ + pull(controller) { + controller.error(new Error('stream error')) + }, + }) as any + ).pipe(res) + }) +}) + const interceptor = new HttpRequestInterceptor() beforeAll(async () => { interceptor.apply() + await httpServer.listen() }) afterEach(() => { @@ -21,6 +37,7 @@ afterEach(() => { afterAll(async () => { interceptor.dispose() + await httpServer.close() }) it('supports ReadableStream as a mocked response', async () => { @@ -111,7 +128,7 @@ it('destroys the socket on stream error if response headers have been sent', asy controller.respondWith(new Response(stream)) }) - const request = http.get('http://localhost/resource') + const request = http.get(httpServer.http.url('/')) const requestErrorListener = vi.fn() request.on('error', requestErrorListener) @@ -122,7 +139,7 @@ it('destroys the socket on stream error if response headers have been sent', asy const responseError = await vi.waitFor(() => { return new Promise((resolve) => { request.on('response', (response) => { - console.log('RESPONSE!') + console.log('response!') response.on('error', resolve) }) }) From 8fc37550bae758fa521f135616d32b5172b037d1 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 6 Aug 2025 19:45:10 +0200 Subject: [PATCH 28/45] fix: unpause the recorder, dedupe `.once` records --- src/interceptors/http/index.ts | 2 + src/interceptors/net/index.ts | 21 ++- src/interceptors/net/socket-recorder.test.ts | 16 +++ src/interceptors/net/socket-recorder.ts | 24 +++- .../compliance/net-socket-passthrough.test.ts | 124 ++++++++++++++++++ 5 files changed, 175 insertions(+), 12 deletions(-) create mode 100644 test/modules/net/compliance/net-socket-passthrough.test.ts diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index a355405c4..856ee608a 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -105,6 +105,8 @@ export class HttpRequestInterceptor extends Interceptor { if (!isRequestHandled) { const passthroughSocket = socket.passthrough() + console.log('pas?') + /** * @note Creating a passthroughsocket does NOT trigger the "socket" event * from `http.ClientRequest` where the request, parser, and socket get diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index af99d9bfd..98fd87be0 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -29,9 +29,16 @@ if (Reflect.get(net.connect, kImplementation) == null) { */ const { connect: originalConnect } = net - Reflect.set(net.connect, kOriginalValue, originalConnect) - Reflect.set(net.connect, kImplementation, () => { - return Reflect.get(net.connect, kOriginalValue) + Object.defineProperties(net.connect, { + [kOriginalValue]: { + value: originalConnect, + }, + [kImplementation]: { + writable: true, + value() { + return Reflect.get(net.connect, kOriginalValue) + }, + }, }) function createSwitchableProxy(target: any) { @@ -80,9 +87,11 @@ export class SocketInterceptor extends Interceptor { * tap into the unpatched `net.connect()`, which will call `socket.connect()`. */ - this.emitter.emit('connection', { - options, - socket, + process.nextTick(() => { + this.emitter.emit('connection', { + options, + socket, + }) }) return socket diff --git a/src/interceptors/net/socket-recorder.test.ts b/src/interceptors/net/socket-recorder.test.ts index 83488cd82..734893551 100644 --- a/src/interceptors/net/socket-recorder.test.ts +++ b/src/interceptors/net/socket-recorder.test.ts @@ -122,6 +122,22 @@ describe('apply', () => { ]) ) }) + + 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', () => { diff --git a/src/interceptors/net/socket-recorder.ts b/src/interceptors/net/socket-recorder.ts index 1f953e53b..25c8e9cba 100644 --- a/src/interceptors/net/socket-recorder.ts +++ b/src/interceptors/net/socket-recorder.ts @@ -1,4 +1,5 @@ import net from 'node:net' +import util from 'node:util' const kSocketRecorder = Symbol('kSocketRecorder') @@ -31,7 +32,7 @@ export function createSocketRecorder( ) => void } ): SocketRecorder { - let isPaused = true + let isPaused = false const entries: Array = [] Object.defineProperty(socket, kSocketRecorder, { @@ -58,26 +59,37 @@ export function createSocketRecorder( typeof target[property as keyof T] === 'function' ) { return new Proxy(target[property as keyof T] as Function, { - apply(fn, thisArg, argArray) { + apply(fn, thisArg, args) { + const defaultApply = () => fn.apply(thisArg, args) + if (fn.name === 'destroy') { entries.length = 0 + return defaultApply() } - if (fn.name !== 'push') { + /** + * @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 }, + metadata: { + property, + }, replay(newSocket) { Reflect.apply( newSocket[property as keyof net.Socket] as Function, newSocket, - argArray + args ) }, }) } - return fn.apply(thisArg, argArray) + return defaultApply() }, }) } 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..1c8cde86e --- /dev/null +++ b/test/modules/net/compliance/net-socket-passthrough.test.ts @@ -0,0 +1,124 @@ +// @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() + + /** + * @fixme Every "once" listener is fired multiple times. + * From the recordings, "once" is followed by "on" immediately + * as if it's implemented by it under the hood. Unit tests for the + * recorder fail to prove that. + */ + 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')) +}) From b54b39b450b025bc3bd3cb9a0afe78bb1eb22e34 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 7 Aug 2025 10:20:04 +0200 Subject: [PATCH 29/45] chore: remove comment, remove unused test --- src/interceptors/http/index.ts | 2 - .../http-client-request-agent.test.ts | 62 ------------- .../intercept/http-client-request.test.ts | 1 - .../http/intercept/http.request.test.ts | 90 +++++++++++++------ .../compliance/net-socket-passthrough.test.ts | 6 -- 5 files changed, 64 insertions(+), 97 deletions(-) delete mode 100644 test/modules/http/intercept/http-client-request-agent.test.ts diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 856ee608a..a355405c4 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -105,8 +105,6 @@ export class HttpRequestInterceptor extends Interceptor { if (!isRequestHandled) { const passthroughSocket = socket.passthrough() - console.log('pas?') - /** * @note Creating a passthroughsocket does NOT trigger the "socket" event * from `http.ClientRequest` where the request, parser, and socket get 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 b0a14e5ec..000000000 --- a/test/modules/http/intercept/http-client-request-agent.test.ts +++ /dev/null @@ -1,62 +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 { HttpRequestInterceptor } from '../../../../src/interceptors/http' -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 { - MockAgent, - MockHttpsAgent, -} from '../../../../src/interceptors/ClientRequest/agents' - -const interceptor = new HttpRequestInterceptor() - -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 74701b1ba..12f9f61dc 100644 --- a/test/modules/http/intercept/http-client-request.test.ts +++ b/test/modules/http/intercept/http-client-request.test.ts @@ -21,7 +21,6 @@ beforeAll(async () => { }) afterEach(() => { - vi.resetAllMocks() interceptor.removeAllListeners() }) diff --git a/test/modules/http/intercept/http.request.test.ts b/test/modules/http/intercept/http.request.test.ts index 2888ab604..06a2b4fd4 100644 --- a/test/modules/http/intercept/http.request.test.ts +++ b/test/modules/http/intercept/http.request.test.ts @@ -18,9 +18,7 @@ const httpServer = new HttpServer((app) => { app.head('/user', handleUserRequest) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() const interceptor = new HttpRequestInterceptor() -interceptor.on('request', resolver) 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/net/compliance/net-socket-passthrough.test.ts b/test/modules/net/compliance/net-socket-passthrough.test.ts index 1c8cde86e..db4308633 100644 --- a/test/modules/net/compliance/net-socket-passthrough.test.ts +++ b/test/modules/net/compliance/net-socket-passthrough.test.ts @@ -61,12 +61,6 @@ it('establishes actual server connection on passthrough', async () => { const connectListener = vi.fn() const errorListener = vi.fn() - /** - * @fixme Every "once" listener is fired multiple times. - * From the recordings, "once" is followed by "on" immediately - * as if it's implemented by it under the hood. Unit tests for the - * recorder fail to prove that. - */ socket .once('connect', function connectOne() { socket.write([ From 4d4f8518eb61383bdf335ef85d1e40ec502b18ab Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 7 Aug 2025 11:10:32 +0200 Subject: [PATCH 30/45] fix: add response stream handling --- src/interceptors/http/http-parser.ts | 53 ++++++---- src/interceptors/http/index.ts | 143 +++++++++++++++++++++------ 2 files changed, 146 insertions(+), 50 deletions(-) diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts index eede6385b..6a79d721e 100644 --- a/src/interceptors/http/http-parser.ts +++ b/src/interceptors/http/http-parser.ts @@ -1,30 +1,41 @@ import { - HeadersCallback, HTTPParser, - RequestHeadersCompleteCallback, + type HeadersCallback, + type RequestHeadersCompleteCallback, + type ResponseHeadersCompleteCallback, } from '_http_common' -export class HttpRequestParser { - #parser: HTTPParser +type HttpParserKind = typeof HTTPParser.REQUEST | typeof HTTPParser.RESPONSE - constructor(options: { - onMessageBegin?: () => void - onHeaders?: HeadersCallback - onHeadersComplete?: RequestHeadersCompleteCallback - 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: { + onMessageBegin?: () => void + onHeaders?: HeadersCallback + onHeadersComplete?: ParserKind extends typeof HTTPParser.REQUEST + ? RequestHeadersCompleteCallback + : ResponseHeadersCompleteCallback + onBody?: (chunk: Buffer) => void + onMessageComplete?: () => void + onExecute?: () => void + onTimeout?: () => void + } + ) { this.#parser = new HTTPParser() - this.#parser.initialize(HTTPParser.REQUEST, {}) - this.#parser[HTTPParser.kOnMessageBegin] = options.onMessageBegin - this.#parser[HTTPParser.kOnHeaders] = options.onHeaders - this.#parser[HTTPParser.kOnHeadersComplete] = options.onHeadersComplete - this.#parser[HTTPParser.kOnBody] = options.onBody - this.#parser[HTTPParser.kOnMessageComplete] = options.onMessageComplete - this.#parser[HTTPParser.kOnExecute] = options.onExecute - this.#parser[HTTPParser.kOnTimeout] = options.onTimeout + 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 { diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index a355405c4..fd961e97e 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -5,8 +5,9 @@ import { Interceptor, INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' import { type HttpRequestEventMap } from '../../glossary' import { SocketInterceptor } from '../net' import { FetchResponse } from '../../utils/fetchUtils' -import { HttpRequestParser } from './http-parser' +import { HttpParser } from './http-parser' import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' +import { MockSocket } from '../net/mock-socket' import type { NetworkConnectionOptions } from '../net/utils/normalize-net-connect-args' import { toBuffer } from './utils/to-buffer' import { createRequestId } from '../../createRequestId' @@ -18,7 +19,7 @@ import { restoreHeadersPrototype, } from '../ClientRequest/utils/recordRawHeaders' import { isResponseError } from '../../utils/responseUtils' -import { MockSocket } from '../Socket/MockSocket' +import { emitAsync } from '../../utils/emitAsync' /** * @fixme Can we use the socket interceptor as a singleton? @@ -79,13 +80,19 @@ export class HttpRequestInterceptor extends Interceptor { requestId, controller, emitter: this.emitter, - async onResponse(response) { + onResponse: async (response) => { await respondWith({ socket, connectionOptions: options, request, response, }) + await emitAsync(this.emitter, 'response', { + requestId, + request, + response, + isMockedResponse: true, + }) }, async onRequestError(response) { await respondWith({ @@ -94,6 +101,12 @@ export class HttpRequestInterceptor extends Interceptor { request, response, }) + await emitAsync(this.emitter, 'response', { + requestId, + request, + response, + isMockedResponse: true, + }) }, onError(error) { if (error instanceof Error) { @@ -103,35 +116,46 @@ export class HttpRequestInterceptor extends Interceptor { }) if (!isRequestHandled) { + // 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) { + createHttpResponseParserStream({ + socket, + onResponse: async (response) => { + await emitAsync(this.emitter, 'response', { + requestId, + request, + response, + isMockedResponse: false, + }) + }, + }) + } + const passthroughSocket = socket.passthrough() /** - * @note Creating a passthroughsocket does NOT trigger the "socket" event - * from `http.ClientRequest` where the request, parser, and socket get - * associated. Recreate that association on the passthrough socket manually. + * @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 Internal Node.js property. + // @ts-expect-error Node.js internals. passthroughSocket._httpMessage = socket._httpMessage - // @ts-expect-error Internal Node.js property.c + // @ts-expect-error Node.js internals. passthroughSocket.parser = socket.parser - // @ts-expect-error Internal Node.js property. + // @ts-expect-error Node.js internals. passthroughSocket.parser.socket = passthroughSocket } }, }) - // Write the header again because at this point it's already been written. + // Write the message header to the parser manually because it's already been written + // on the socket so it won't get piped. requestParser.write(toBuffer(chunk, encoding)) socket.pipe(requestParser) }) - - socket.on('push', (chunk, encoding) => { - /** - * @todo Route this through a response parser so both mocked - * and passthrough responses emit the "response" event for the user. - */ - }) }) }) } @@ -146,7 +170,7 @@ function createHttpRequestParserStream(options: { const requestRawHeadersBuffer: Array = [] let requestBodyStream: Readable | undefined - const parser = new HttpRequestParser({ + const parser = new HttpParser(HttpParser.REQUEST, { onHeaders(rawHeaders) { requestRawHeadersBuffer.push(...rawHeaders) }, @@ -210,7 +234,7 @@ function createHttpRequestParserStream(options: { onBody(chunk) { invariant( requestBodyStream, - 'Failed to write to a request stream: stream does not exist' + 'Failed to write to a request stream: stream does not exist. This is likely an issue with the library. Please report it on GitHub.' ) requestBodyStream.push(chunk) @@ -227,13 +251,75 @@ function createHttpRequestParserStream(options: { }, }) - parserStream.once('finish', () => { - parser.free() - }) + parserStream + .once('finish', () => parser.free()) + .once('error', () => parser.free()) return parserStream } +function createHttpResponseParserStream(options: { + socket: MockSocket + onResponse: (response: Response) => void +}) { + const { socket, onResponse } = options + const responseRawHeadersBuffer: Array = [] + let responseBodyStream: Readable | undefined + + const parser = new HttpParser(HttpParser.RESPONSE, { + onHeaders(rawHeaders) { + responseRawHeadersBuffer.push(...rawHeaders) + }, + onHeadersComplete( + versionMajor, + versionMinor, + rawHeaders, + method, + url, + status, + statusText + ) { + const headers = FetchResponse.parseRawHeaders([ + ...responseRawHeadersBuffer, + ...(rawHeaders || []), + ]) + + const response = new FetchResponse( + FetchResponse.isResponseWithBody(status) + ? (Readable.toWeb( + (responseBodyStream = new Readable({ read() {} })) + ) as any) + : null, + { + url, + status, + statusText, + headers, + } + ) + + onResponse(response) + }, + onBody(chunk) { + invariant( + 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.' + ) + + responseBodyStream.push(chunk) + }, + onMessageComplete() { + responseBodyStream?.push(null) + }, + }) + + socket + .on('push', (chunk, encoding) => { + parser.execute(toBuffer(chunk, encoding)) + }) + .once('close', () => parser.free()) +} + /** * Mocks a successful socket connection. */ @@ -314,6 +400,11 @@ async function respondWith(args: { // Construct a regular server response to delegate body parsing to Node.js. const serverResponse = new ServerResponse(new IncomingMessage(socket)) + const responseSocket = new MockSocket({} as any) + responseSocket.on('write', (chunk, encoding, callback) => { + socket.push(chunk, encoding) + callback?.() + }) serverResponse.assignSocket( /** @@ -321,13 +412,7 @@ async function respondWith(args: { * 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 MockSocket({ - write(chunk, encoding, callback) { - socket.push(chunk, encoding) - callback?.() - }, - read() {}, - }) + responseSocket ) /** From 97f678bfdfc60773d941469a02517224bc4e313c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 7 Aug 2025 11:14:35 +0200 Subject: [PATCH 31/45] fix: use `Writable` for dummy response stream --- src/interceptors/http/index.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index fd961e97e..1649b5b53 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -400,19 +400,19 @@ async function respondWith(args: { // Construct a regular server response to delegate body parsing to Node.js. const serverResponse = new ServerResponse(new IncomingMessage(socket)) - const responseSocket = new MockSocket({} as any) - responseSocket.on('write', (chunk, encoding, callback) => { - socket.push(chunk, encoding) - callback?.() - }) serverResponse.assignSocket( /** - * @note Provide a dummy socket to the server response to translate all its writes + * @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). */ - responseSocket + new Writable({ + write(chunk, encoding, callback) { + socket.push(chunk, encoding) + callback?.() + }, + }) as net.Socket ) /** From 535facd30adeaccf05916a9cea7022d7812e3746 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 7 Aug 2025 11:17:51 +0200 Subject: [PATCH 32/45] fix: free request parser on `close` --- src/interceptors/http/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 1649b5b53..56750502f 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -229,6 +229,16 @@ function createHttpRequestParserStream(options: { body: canHaveBody ? (Readable.toWeb(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) { @@ -253,7 +263,7 @@ function createHttpRequestParserStream(options: { parserStream .once('finish', () => parser.free()) - .once('error', () => parser.free()) + .once('close', () => parser.free()) return parserStream } From e375e22830d5b787d09b17781d5e4a7384f9c7c7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 7 Aug 2025 14:40:38 +0200 Subject: [PATCH 33/45] fix: request body reads, response piping --- src/interceptors/http/index.ts | 110 +++++++++++--------- test/modules/http/compliance/events.test.ts | 70 +++++++------ 2 files changed, 100 insertions(+), 80 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 56750502f..f9749f057 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -1,13 +1,12 @@ import net from 'node:net' import { Readable, Writable } from 'node:stream' import { invariant } from 'outvariant' -import { Interceptor, INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' +import { Interceptor } from '../../Interceptor' import { type HttpRequestEventMap } from '../../glossary' import { SocketInterceptor } from '../net' import { FetchResponse } from '../../utils/fetchUtils' import { HttpParser } from './http-parser' import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' -import { MockSocket } from '../net/mock-socket' import type { NetworkConnectionOptions } from '../net/utils/normalize-net-connect-args' import { toBuffer } from './utils/to-buffer' import { createRequestId } from '../../createRequestId' @@ -81,32 +80,44 @@ export class HttpRequestInterceptor extends Interceptor { controller, emitter: this.emitter, onResponse: 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 respondWith({ socket, connectionOptions: options, request, response, }) - await emitAsync(this.emitter, 'response', { - requestId, - request, - response, - isMockedResponse: true, - }) }, async onRequestError(response) { + if (this.emitter.listenerCount('response') > 0) { + const responseClone = response.clone() + process.nextTick(() => { + emitAsync(this.emitter, 'response', { + requestId, + request, + response: responseClone, + isMockedResponse: true, + }) + }) + } + await respondWith({ socket, connectionOptions: options, request, response, }) - await emitAsync(this.emitter, 'response', { - requestId, - request, - response, - isMockedResponse: true, - }) }, onError(error) { if (error instanceof Error) { @@ -116,22 +127,6 @@ export class HttpRequestInterceptor extends Interceptor { }) if (!isRequestHandled) { - // 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) { - createHttpResponseParserStream({ - socket, - onResponse: async (response) => { - await emitAsync(this.emitter, 'response', { - requestId, - request, - response, - isMockedResponse: false, - }) - }, - }) - } - const passthroughSocket = socket.passthrough() /** @@ -147,6 +142,25 @@ export class HttpRequestInterceptor extends Interceptor { 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 responseStream = createHttpResponseParserStream({ + onResponse: async (response) => { + await emitAsync(this.emitter, 'response', { + requestId, + request, + response, + isMockedResponse: false, + }) + }, + }) + + passthroughSocket + .on('data', (chunk) => responseStream.write(chunk)) + .on('close', () => responseStream.end()) + } } }, }) @@ -154,7 +168,13 @@ export class HttpRequestInterceptor extends Interceptor { // Write the message header to the parser manually because it's already been written // on the socket so it won't get piped. requestParser.write(toBuffer(chunk, encoding)) - socket.pipe(requestParser) + + socket + .on('write', (chunk, encoding, callback) => { + requestParser.write(chunk, encoding, callback) + }) + .on('finish', () => requestParser.end()) + .on('error', () => requestParser.end()) }) }) }) @@ -211,13 +231,7 @@ function createHttpRequestParserStream(options: { * @note Provide the `read()` method so a `Readable` could be * used as the actual request body (the stream calls "read()"). */ - read() { - // If the user attempts to read the request body, - // flush the write buffer to trigger the callbacks. - // This way, if the request stream ends in the write callback, - // it will indeed end correctly. - // flushWriteBuffer() - }, + read() {}, }) const request = new Request(url, { @@ -254,25 +268,18 @@ function createHttpRequestParserStream(options: { }, }) - const parserStream = new Writable({ + return new Writable({ write(chunk, encoding, callback) { parser.execute(toBuffer(chunk, encoding)) callback() }, }) - - parserStream - .once('finish', () => parser.free()) - .once('close', () => parser.free()) - - return parserStream } function createHttpResponseParserStream(options: { - socket: MockSocket onResponse: (response: Response) => void }) { - const { socket, onResponse } = options + const { onResponse } = options const responseRawHeadersBuffer: Array = [] let responseBodyStream: Readable | undefined @@ -323,11 +330,12 @@ function createHttpResponseParserStream(options: { }, }) - socket - .on('push', (chunk, encoding) => { + return new Writable({ + write(chunk, encoding, callback) { parser.execute(toBuffer(chunk, encoding)) - }) - .once('close', () => parser.free()) + callback() + }, + }) } /** diff --git a/test/modules/http/compliance/events.test.ts b/test/modules/http/compliance/events.test.ts index beb1952d6..bcb09707d 100644 --- a/test/modules/http/compliance/events.test.ts +++ b/test/modules/http/compliance/events.test.ts @@ -9,6 +9,9 @@ const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { res.send('original-response') }) + app.post('/', (req, res) => { + res.send() + }) }) const interceptor = new HttpRequestInterceptor() @@ -18,6 +21,10 @@ beforeAll(async () => { await httpServer.listen() }) +afterEach(() => { + interceptor.removeAllListeners() +}) + afterAll(async () => { interceptor.dispose() await httpServer.close() @@ -48,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', @@ -62,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 () => { @@ -99,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 () => { @@ -135,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) }) From df151b79992eec83999b4b6e3cbbaa3eca307f94 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 8 Aug 2025 12:46:25 +0200 Subject: [PATCH 34/45] fix: response stream error handling --- src/interceptors/http/http-parser.ts | 1 + src/interceptors/http/index.ts | 28 +++---- src/interceptors/net/index.ts | 69 +++++++++++++++--- src/interceptors/net/socket-recorder.test.ts | 22 ++++++ src/interceptors/net/socket-recorder.ts | 18 ++++- .../http/response/http-response-error.test.ts | 25 +++---- .../http-response-readable-stream.test.ts | 73 ++++++++++++------- 7 files changed, 168 insertions(+), 68 deletions(-) diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts index 6a79d721e..53697aac6 100644 --- a/src/interceptors/http/http-parser.ts +++ b/src/interceptors/http/http-parser.ts @@ -43,6 +43,7 @@ export class HttpParser { } public free(): void { + this.#parser.finish() this.#parser.free() } } diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index f9749f057..928641040 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -100,18 +100,6 @@ export class HttpRequestInterceptor extends Interceptor { }) }, async onRequestError(response) { - if (this.emitter.listenerCount('response') > 0) { - const responseClone = response.clone() - process.nextTick(() => { - emitAsync(this.emitter, 'response', { - requestId, - request, - response: responseClone, - isMockedResponse: true, - }) - }) - } - await respondWith({ socket, connectionOptions: options, @@ -171,7 +159,9 @@ export class HttpRequestInterceptor extends Interceptor { socket .on('write', (chunk, encoding, callback) => { - requestParser.write(chunk, encoding, callback) + if (chunk) { + requestParser.write(chunk, encoding, callback) + } }) .on('finish', () => requestParser.end()) .on('error', () => requestParser.end()) @@ -273,6 +263,9 @@ function createHttpRequestParserStream(options: { parser.execute(toBuffer(chunk, encoding)) callback() }, + destroy() { + parser.free() + }, }) } @@ -335,6 +328,9 @@ function createHttpResponseParserStream(options: { parser.execute(toBuffer(chunk, encoding)) callback() }, + destroy() { + parser.free() + }, }) } @@ -477,8 +473,12 @@ async function respondWith(args: { /** * 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(error) + socket.destroy() } } } else { diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 98fd87be0..ad4900df2 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -1,4 +1,5 @@ import net from 'node:net' +import tls from 'node:tls' import { Interceptor } from '../../Interceptor' import { MockSocket } from './mock-socket' import { @@ -19,6 +20,14 @@ export interface SocketConnectionEventMap { 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. @@ -41,14 +50,6 @@ if (Reflect.get(net.connect, kImplementation) == null) { }, }) - function createSwitchableProxy(target: any) { - return new Proxy(target, { - apply(target, thisArg, argArray) { - return Reflect.get(target, kImplementation).apply(thisArg, argArray) - }, - }) - } - net.connect = createSwitchableProxy(net.connect) /** * `net.createConnection` is an alias for `net.connect`. @@ -57,6 +58,24 @@ if (Reflect.get(net.connect, kImplementation) == null) { 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') @@ -65,17 +84,21 @@ export class SocketInterceptor extends Interceptor { } protected setup(): void { - const originalConnect = Reflect.get(net.connect, kOriginalValue) + const originalNetConnect = Reflect.get(net.connect, kOriginalValue) + this.subscriptions.push(() => { + Reflect.set(net.connect, kImplementation, originalNetConnect) + }) Reflect.set(net.connect, kImplementation, (...args: Array) => { const [options, connectionCallback] = normalizeNetConnectArgs( args as NetConnectArgs ) + const socket = new MockSocket({ ...args, connectionCallback, createConnection() { - return originalConnect(...args) + return originalNetConnect(...args) }, }) @@ -97,8 +120,32 @@ export class SocketInterceptor extends Interceptor { return socket }) + const originalTlsConnect = Reflect.get(tls.connect, kOriginalValue) this.subscriptions.push(() => { - Reflect.set(net.connect, kImplementation, originalConnect) + Reflect.set(tls.connect, kImplementation, originalTlsConnect) + }) + + Reflect.set(tls.connect, kImplementation, (...args: Array) => { + const [options, connectionCallback] = normalizeNetConnectArgs( + args as NetConnectArgs + ) + + const socket = new MockSocket({ + ...args, + connectionCallback, + createConnection() { + return originalTlsConnect(...args) + }, + }) + + process.nextTick(() => { + this.emitter.emit('connection', { + options, + socket, + }) + }) + + return socket }) } } diff --git a/src/interceptors/net/socket-recorder.test.ts b/src/interceptors/net/socket-recorder.test.ts index 734893551..635bbed67 100644 --- a/src/interceptors/net/socket-recorder.test.ts +++ b/src/interceptors/net/socket-recorder.test.ts @@ -47,6 +47,28 @@ describe('set', () => { ) }) + 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', () => {}) diff --git a/src/interceptors/net/socket-recorder.ts b/src/interceptors/net/socket-recorder.ts index 25c8e9cba..746769783 100644 --- a/src/interceptors/net/socket-recorder.ts +++ b/src/interceptors/net/socket-recorder.ts @@ -1,5 +1,4 @@ import net from 'node:net' -import util from 'node:util' const kSocketRecorder = Symbol('kSocketRecorder') @@ -16,6 +15,21 @@ export interface SocketRecorderEntry { 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 @@ -104,7 +118,7 @@ export function createSocketRecorder( return Reflect.set(target, property, newValue, receiver) } - if (typeof property === 'symbol' || property.startsWith('_')) { + if (typeof property === 'symbol' || isInternalSetter(property)) { return defaultSetter() } diff --git a/test/modules/http/response/http-response-error.test.ts b/test/modules/http/response/http-response-error.test.ts index 2a5ef2d43..a4778e3ef 100644 --- a/test/modules/http/response/http-response-error.test.ts +++ b/test/modules/http/response/http-response-error.test.ts @@ -28,13 +28,12 @@ it('treats "Response.error()" as a network error', async () => { const request = http.get('http://localhost:3001/resource') request.on('error', requestErrorListener) - // Must handle Response.error() as a network error. - await vi.waitFor(() => { - expect(requestErrorListener).toHaveBeenNthCalledWith( - 1, - new TypeError('Network error') - ) - }) + await expect + .poll(() => requestErrorListener, { + message: 'Must treat Response.error() as a network error', + }) + .toHaveBeenCalledWith(new TypeError('Network error')) + expect(requestErrorListener).toHaveBeenCalledOnce() expect(responseListener).not.toHaveBeenCalled() }) @@ -52,12 +51,12 @@ it('treats a thrown Response.error() as a network error', async () => { request.on('error', requestErrorListener) // Must handle Response.error() as a request error. - await vi.waitFor(() => { - expect(requestErrorListener).toHaveBeenNthCalledWith( - 1, - new TypeError('Network error') - ) - }) + await expect + .poll(() => requestErrorListener, { + message: 'Must treat Response.error() as a network error', + }) + .toHaveBeenCalledWith(new TypeError('Network error')) + expect(requestErrorListener).toHaveBeenCalledOnce() expect(responseListener).not.toHaveBeenCalled() }) 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 d75e3a8d7..d26fccbea 100644 --- a/test/modules/http/response/http-response-readable-stream.test.ts +++ b/test/modules/http/response/http-response-readable-stream.test.ts @@ -1,8 +1,7 @@ // @vitest-environment node import { HttpRequestInterceptor } from '../../../../src/interceptors/http' -import { performance } from 'node:perf_hooks' import http from 'node:http' -import { Readable } from 'node:stream' +import { performance } from 'node:perf_hooks' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpServer } from '@open-draft/test-server/http' import { sleep, waitForClientRequest } from '../../../helpers' @@ -14,13 +13,7 @@ const encoder = new TextEncoder() const httpServer = new HttpServer((app) => { app.get('/', (req, res) => { res.writeHead(200) - Readable.fromWeb( - new ReadableStream({ - pull(controller) { - controller.error(new Error('stream error')) - }, - }) as any - ).pipe(res) + res.destroy(new Error('stream error')) }) }) @@ -43,7 +36,7 @@ afterAll(async () => { it('supports ReadableStream as a mocked response', async () => { 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')) @@ -61,7 +54,7 @@ it('supports ReadableStream as a mocked response', async () => { it('supports delays when enqueuing chunks', async () => { interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ - async start(controller) { + async pull(controller) { controller.enqueue(encoder.encode('first')) await sleep(200) @@ -117,11 +110,37 @@ it('supports delays when enqueuing chunks', async () => { expect(chunkTimings[2] - chunkTimings[1]).toBeGreaterThanOrEqual(150) }) -it('destroys the socket on stream error if response headers have been sent', async () => { +it('emits request error if the response stream errors (bypass)', async () => { + const request = http.get(httpServer.http.url('/')) + + const requestErrorListener = vi.fn() + request.on('error', requestErrorListener) + + const requestCloseListener = vi.fn() + request.on('close', requestCloseListener) + + const responseErrorListener = vi.fn() + request.on('response', (response) => { + response.on('error', responseErrorListener) + }) + + await expect.poll(() => request.destroyed).toBe(true) + + expect.soft(requestErrorListener).toHaveBeenCalledOnce() + expect.soft(requestErrorListener).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'socket hang up', + }) + ) + expect.soft(requestCloseListener).toHaveBeenCalledOnce() + expect.soft(responseErrorListener).not.toHaveBeenCalled() +}) + +it('emits request error if the response stream errors (mock)', async () => { interceptor.on('request', ({ controller }) => { const stream = new ReadableStream({ - async start(controller) { - controller.enqueue(new TextEncoder().encode('original')) + async pull(controller) { + controller.enqueue(new TextEncoder().encode('hello world')) controller.error(new Error('stream error')) }, }) @@ -136,21 +155,19 @@ it('destroys the socket on stream error if response headers have been sent', asy const requestCloseListener = vi.fn() request.on('close', requestCloseListener) - const responseError = await vi.waitFor(() => { - return new Promise((resolve) => { - request.on('response', (response) => { - console.log('response!') - response.on('error', resolve) - }) - }) + const responseErrorListener = vi.fn() + request.on('response', (response) => { + response.on('error', responseErrorListener) }) - expect.soft(request.destroyed).toBe(true) - expect.soft(responseError).toBeInstanceOf(Error) - expect.soft(responseError.message).toBe('stream error') + await expect.poll(() => request.destroyed).toBe(true) - expect - .soft(requestErrorListener, 'Request must not error') - .not.toHaveBeenCalled() - expect.soft(requestCloseListener, 'Request must close').toHaveBeenCalledOnce() + expect.soft(requestErrorListener).toHaveBeenCalledOnce() + expect.soft(requestErrorListener).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'socket hang up', + }) + ) + expect.soft(requestCloseListener).toHaveBeenCalledOnce() + expect.soft(responseErrorListener).not.toHaveBeenCalled() }) From 288bc56523dd4c004bc1fc47a41a0644d518d238 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 8 Aug 2025 12:56:15 +0200 Subject: [PATCH 35/45] fix: set `options.secure` for tls connections --- .../utils/baseUrlFromConnectionOptions.ts | 8 +- src/interceptors/net/index.ts | 1 + src/interceptors/net/mock-socket.ts | 1 + .../net/utils/normalize-net-connect-args.ts | 1 + .../http/intercept/https.request.test.ts | 173 +++++++++++------- 5 files changed, 113 insertions(+), 71 deletions(-) diff --git a/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts b/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts index 6a4f33adb..0f90f74de 100644 --- a/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts +++ b/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts @@ -3,7 +3,13 @@ export function baseUrlFromConnectionOptions(options: any): URL { return new URL(options.href) } - const protocol = options.port === 443 ? 'https:' : 'http:' + const protocol = + options.protocol || options.secure + ? 'https:' + : options.port === 443 + ? 'https:' + : 'http:' + const host = options.host const url = new URL(`${protocol}//${host}`) diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index ad4900df2..1d980defe 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -129,6 +129,7 @@ export class SocketInterceptor extends Interceptor { const [options, connectionCallback] = normalizeNetConnectArgs( args as NetConnectArgs ) + options.secure = true const socket = new MockSocket({ ...args, diff --git a/src/interceptors/net/mock-socket.ts b/src/interceptors/net/mock-socket.ts index 78c02ec9a..4ffd409a8 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -6,6 +6,7 @@ import { import { createSocketRecorder, type SocketRecorder } from './socket-recorder' interface MockSocketConstructorOptions extends net.SocketConstructorOpts { + secure?: boolean createConnection: () => net.Socket connectionCallback?: () => void } diff --git a/src/interceptors/net/utils/normalize-net-connect-args.ts b/src/interceptors/net/utils/normalize-net-connect-args.ts index ac57ecd7a..d60e453ca 100644 --- a/src/interceptors/net/utils/normalize-net-connect-args.ts +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -1,6 +1,7 @@ import net from 'node:net' export interface NetworkConnectionOptions { + secure?: boolean port?: number path: string host?: string diff --git a/test/modules/http/intercept/https.request.test.ts b/test/modules/http/intercept/https.request.test.ts index 30018c765..bdfa4928c 100644 --- a/test/modules/http/intercept/https.request.test.ts +++ b/test/modules/http/intercept/https.request.test.ts @@ -20,17 +20,15 @@ const httpServer = new HttpServer((app) => { app.head('/user', handleUserRequest) }) -const resolver = vi.fn<(...args: HttpRequestEventMap['request']) => void>() const interceptor = new HttpRequestInterceptor() -interceptor.on('request', resolver) 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) From abdb4fe67a1557ec180f663961e7f2834ad2bd70 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 8 Aug 2025 13:00:51 +0200 Subject: [PATCH 36/45] fix: protocol resolution for tls connections --- .../utils/baseUrlFromConnectionOptions.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts b/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts index 0f90f74de..a6cfeee1c 100644 --- a/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts +++ b/src/interceptors/Socket/utils/baseUrlFromConnectionOptions.ts @@ -3,13 +3,7 @@ export function baseUrlFromConnectionOptions(options: any): URL { return new URL(options.href) } - const protocol = - options.protocol || options.secure - ? 'https:' - : options.port === 443 - ? 'https:' - : 'http:' - + const protocol = getProtocolByOptions(options) const host = options.host const url = new URL(`${protocol}//${host}`) @@ -30,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:' +} From d285a5e76efb0efc1d277ce79b4acfd5f737e599 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 8 Aug 2025 13:13:42 +0200 Subject: [PATCH 37/45] test: use `https.globalAgent` for HTTPS `ClientRequest` --- .../http/intercept/http-client-request.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/modules/http/intercept/http-client-request.test.ts b/test/modules/http/intercept/http-client-request.test.ts index 12f9f61dc..663c55cb1 100644 --- a/test/modules/http/intercept/http-client-request.test.ts +++ b/test/modules/http/intercept/http-client-request.test.ts @@ -1,6 +1,7 @@ // @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 { REQUEST_ID_REGEXP, waitForClientRequest } from '../../../helpers' import { RequestController } from '../../../../src/RequestController' @@ -16,8 +17,8 @@ const interceptor = new HttpRequestInterceptor() beforeAll(async () => { process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' - await httpServer.listen() interceptor.apply() + await httpServer.listen() }) afterEach(() => { @@ -134,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() @@ -164,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() @@ -195,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, From 25f7eb8694f0d86690e241fb9d5b1e603dc0e8a6 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 8 Aug 2025 13:38:09 +0200 Subject: [PATCH 38/45] fix: implement `connectOptionsToUrl` --- src/interceptors/http/index.ts | 8 ++- .../net/utils/connect-options-to-url.ts | 49 +++++++++++++++++++ .../net/utils/normalize-net-connect-args.ts | 28 ++++++----- 3 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 src/interceptors/net/utils/connect-options-to-url.ts diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 928641040..7c60acd78 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -19,6 +19,7 @@ import { } 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? @@ -65,9 +66,12 @@ export class HttpRequestInterceptor extends Interceptor { options ) + const baseUrl = connectOptionsToUrl(options) + const requestParser = createHttpRequestParserStream({ requestOptions: { method, + baseUrl, ...options, }, onRequest: async (request) => { @@ -174,6 +178,7 @@ export class HttpRequestInterceptor extends Interceptor { function createHttpRequestParserStream(options: { requestOptions: NetworkConnectionOptions & { method: string + baseUrl: URL } onRequest: (request: Request) => void }) { @@ -196,8 +201,7 @@ function createHttpRequestParserStream(options: { shouldKeepAlive ) { const method = options.requestOptions.method?.toUpperCase() || 'GET' - const baseUrl = baseUrlFromConnectionOptions(options.requestOptions) - const url = new URL(path || '', baseUrl) + const url = new URL(path || '', options.requestOptions.baseUrl) const headers = FetchResponse.parseRawHeaders([ ...requestRawHeadersBuffer, 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.ts b/src/interceptors/net/utils/normalize-net-connect-args.ts index d60e453ca..e63d20a98 100644 --- a/src/interceptors/net/utils/normalize-net-connect-args.ts +++ b/src/interceptors/net/utils/normalize-net-connect-args.ts @@ -1,16 +1,17 @@ import net from 'node:net' +import url from 'node:url' export interface NetworkConnectionOptions { - secure?: boolean - port?: number - path: string - host?: string - protocol?: string - auth?: string - family?: number + 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 - localPort?: number + localAddress?: string | null + localPort?: number | null } export type NetConnectArgs = @@ -42,12 +43,15 @@ export function normalizeNetConnectArgs( if (typeof args[0] === 'object') { if ('href' in args[0]) { + const options = url.urlToHttpOptions(args[0]) + return [ { - path: args[0].pathname || '', - port: +args[0].port, - host: args[0].hostname, protocol: args[0].protocol, + path: options.path || '', + port: +args[0].port, + host: options.hostname, + auth: options.auth, }, callback, ] From d2c36d3b058e1ff9b0c1e009451485ad6c405c26 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 8 Aug 2025 13:42:22 +0200 Subject: [PATCH 39/45] docs: mention forgoing stream piping --- src/interceptors/http/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 7c60acd78..16c6df5e6 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -161,6 +161,13 @@ export class HttpRequestInterceptor extends Interceptor { // on the socket so it won't get piped. requestParser.write(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! + */ socket .on('write', (chunk, encoding, callback) => { if (chunk) { From 371f98cb92da754dcebbad0eee03683122e05209 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 8 Aug 2025 13:42:34 +0200 Subject: [PATCH 40/45] chore: remove unused import --- src/interceptors/http/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 16c6df5e6..26d1613ba 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -6,7 +6,6 @@ import { type HttpRequestEventMap } from '../../glossary' import { SocketInterceptor } from '../net' import { FetchResponse } from '../../utils/fetchUtils' import { HttpParser } from './http-parser' -import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions' import type { NetworkConnectionOptions } from '../net/utils/normalize-net-connect-args' import { toBuffer } from './utils/to-buffer' import { createRequestId } from '../../createRequestId' From 6318aeac48b81c7765def0ccab37c5273bd88d1c Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 8 Aug 2025 14:39:22 +0200 Subject: [PATCH 41/45] fix: free parsers correctly --- _http_common.d.ts | 29 +++- src/interceptors/http/http-parser.ts | 230 ++++++++++++++++++++++++--- src/interceptors/http/index.ts | 186 ++-------------------- 3 files changed, 244 insertions(+), 201 deletions(-) diff --git a/_http_common.d.ts b/_http_common.d.ts index 1cb16d91c..cb9450d37 100644 --- a/_http_common.d.ts +++ b/_http_common.d.ts @@ -18,20 +18,33 @@ declare var HTTPParser: { export interface HTTPParser { new (): HTTPParser - [HTTPParser.kOnMessageBegin]?: () => void + [HTTPParser.kOnMessageBegin]?: (() => void) | null [HTTPParser.kOnHeaders]?: HeadersCallback [HTTPParser.kOnHeadersComplete]?: ParserType extends 0 - ? RequestHeadersCompleteCallback - : ResponseHeadersCompleteCallback - [HTTPParser.kOnBody]?: (chunk: Buffer) => void - [HTTPParser.kOnMessageComplete]?: () => void - [HTTPParser.kOnExecute]?: () => void - [HTTPParser.kOnTimeout]?: () => void + ? 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 diff --git a/src/interceptors/http/http-parser.ts b/src/interceptors/http/http-parser.ts index 53697aac6..55c7919ce 100644 --- a/src/interceptors/http/http-parser.ts +++ b/src/interceptors/http/http-parser.ts @@ -4,29 +4,33 @@ import { 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: { - onMessageBegin?: () => void - onHeaders?: HeadersCallback - onHeadersComplete?: ParserKind extends typeof HTTPParser.REQUEST - ? RequestHeadersCompleteCallback - : ResponseHeadersCompleteCallback - onBody?: (chunk: Buffer) => void - onMessageComplete?: () => void - onExecute?: () => void - onTimeout?: () => void - } - ) { + #parser: HTTPParser + + constructor(kind: ParserKind, hooks: ParserHooks) { this.#parser = new HTTPParser() this.#parser.initialize(kind, {}) this.#parser[HTTPParser.kOnMessageBegin] = hooks.onMessageBegin @@ -42,8 +46,198 @@ export class HttpParser { this.#parser.execute(buffer) } - public free(): void { - this.#parser.finish() + /** + * @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 index 26d1613ba..6d6ef5adc 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -1,11 +1,10 @@ import net from 'node:net' -import { Readable, Writable } from 'node:stream' +import { Writable } from 'node:stream' import { invariant } from 'outvariant' import { Interceptor } from '../../Interceptor' import { type HttpRequestEventMap } from '../../glossary' import { SocketInterceptor } from '../net' -import { FetchResponse } from '../../utils/fetchUtils' -import { HttpParser } from './http-parser' +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' @@ -67,7 +66,7 @@ export class HttpRequestInterceptor extends Interceptor { const baseUrl = connectOptionsToUrl(options) - const requestParser = createHttpRequestParserStream({ + const requestParser = new HttpRequestParser({ requestOptions: { method, baseUrl, @@ -137,7 +136,7 @@ export class HttpRequestInterceptor extends Interceptor { // 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 responseStream = createHttpResponseParserStream({ + const responseParser = new HttpResponseParser({ onResponse: async (response) => { await emitAsync(this.emitter, 'response', { requestId, @@ -149,8 +148,8 @@ export class HttpRequestInterceptor extends Interceptor { }) passthroughSocket - .on('data', (chunk) => responseStream.write(chunk)) - .on('close', () => responseStream.end()) + .on('data', (chunk) => responseParser.execute(chunk)) + .on('close', () => responseParser.free(passthroughSocket)) } } }, @@ -158,7 +157,7 @@ export class HttpRequestInterceptor extends Interceptor { // Write the message header to the parser manually because it's already been written // on the socket so it won't get piped. - requestParser.write(toBuffer(chunk, encoding)) + requestParser.execute(toBuffer(chunk, encoding)) /** * @note Listen to the internal "write" event and call the parser manually. @@ -168,182 +167,19 @@ export class HttpRequestInterceptor extends Interceptor { * because the stream is paused! */ socket - .on('write', (chunk, encoding, callback) => { + .on('write', (chunk, encoding) => { if (chunk) { - requestParser.write(chunk, encoding, callback) + requestParser.execute(toBuffer(chunk, encoding)) } }) - .on('finish', () => requestParser.end()) - .on('error', () => requestParser.end()) + .on('finish', () => requestParser.free(socket)) + .on('error', () => requestParser.free(socket)) }) }) }) } } -function createHttpRequestParserStream(options: { - requestOptions: NetworkConnectionOptions & { - method: string - baseUrl: URL - } - onRequest: (request: Request) => void -}) { - const requestRawHeadersBuffer: Array = [] - let requestBodyStream: Readable | undefined - - const parser = new HttpParser(HttpParser.REQUEST, { - onHeaders(rawHeaders) { - 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([ - ...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 = '' - } - - 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(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( - 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.' - ) - - requestBodyStream.push(chunk) - }, - onMessageComplete() { - requestBodyStream?.push(null) - }, - }) - - return new Writable({ - write(chunk, encoding, callback) { - parser.execute(toBuffer(chunk, encoding)) - callback() - }, - destroy() { - parser.free() - }, - }) -} - -function createHttpResponseParserStream(options: { - onResponse: (response: Response) => void -}) { - const { onResponse } = options - const responseRawHeadersBuffer: Array = [] - let responseBodyStream: Readable | undefined - - const parser = new HttpParser(HttpParser.RESPONSE, { - onHeaders(rawHeaders) { - responseRawHeadersBuffer.push(...rawHeaders) - }, - onHeadersComplete( - versionMajor, - versionMinor, - rawHeaders, - method, - url, - status, - statusText - ) { - const headers = FetchResponse.parseRawHeaders([ - ...responseRawHeadersBuffer, - ...(rawHeaders || []), - ]) - - const response = new FetchResponse( - FetchResponse.isResponseWithBody(status) - ? (Readable.toWeb( - (responseBodyStream = new Readable({ read() {} })) - ) as any) - : null, - { - url, - status, - statusText, - headers, - } - ) - - onResponse(response) - }, - onBody(chunk) { - invariant( - 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.' - ) - - responseBodyStream.push(chunk) - }, - onMessageComplete() { - responseBodyStream?.push(null) - }, - }) - - return new Writable({ - write(chunk, encoding, callback) { - parser.execute(toBuffer(chunk, encoding)) - callback() - }, - destroy() { - parser.free() - }, - }) -} - /** * Mocks a successful socket connection. */ From 35142dcd1401563f42ec806fc748939e45169723 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 8 Aug 2025 14:49:15 +0200 Subject: [PATCH 42/45] fix: remove socket argument from freeing the parser --- src/interceptors/http/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 6d6ef5adc..58c3229b3 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -149,7 +149,7 @@ export class HttpRequestInterceptor extends Interceptor { passthroughSocket .on('data', (chunk) => responseParser.execute(chunk)) - .on('close', () => responseParser.free(passthroughSocket)) + .on('close', () => responseParser.free()) } } }, @@ -172,8 +172,8 @@ export class HttpRequestInterceptor extends Interceptor { requestParser.execute(toBuffer(chunk, encoding)) } }) - .on('finish', () => requestParser.free(socket)) - .on('error', () => requestParser.free(socket)) + .on('finish', () => requestParser.free()) + .on('error', () => requestParser.free()) }) }) }) From e7abf9fdc11d78291d1ed9701d926d5cb3637485 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 8 Aug 2025 14:52:58 +0200 Subject: [PATCH 43/45] chore: move `mockConnect` and `respondWith` into the interceptor --- src/interceptors/http/index.ts | 288 ++++++++++++++++----------------- 1 file changed, 144 insertions(+), 144 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 58c3229b3..e27a8673b 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -94,15 +94,15 @@ export class HttpRequestInterceptor extends Interceptor { }) } - await respondWith({ + await this.respondWith({ socket, connectionOptions: options, request, response, }) }, - async onRequestError(response) { - await respondWith({ + onRequestError: async (response) => { + await this.respondWith({ socket, connectionOptions: options, request, @@ -178,166 +178,166 @@ export class HttpRequestInterceptor extends Interceptor { }) }) } -} - -/** - * Mocks a successful socket connection. - */ -function 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? + * 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 (connectionOptions.protocol === 'https:') { - socket.emit('secure') - socket.emit('secureConnect') socket.emit( - 'session', - connectionOptions.session || Buffer.from('mock-session-renegotiate') + 'lookup', + null, + addressInfo, + addressInfo.family === 'IPv6' ? 6 : 4, + connectionOptions.host ) - socket.emit('session', Buffer.from('mock-session-resume')) + socket.emit('connect') + socket.emit('ready') + + if (connectionOptions.protocol === 'https:') { + socket.emit('secure') + socket.emit('secureConnect') + socket.emit( + 'session', + connectionOptions.session || Buffer.from('mock-session-renegotiate') + ) + socket.emit('session', Buffer.from('mock-session-resume')) + } } -} -/** - * Pushes the given Fetch API `Response` onto the given socket. - * Automatically establishes a successful mock socket connection. - */ -async function 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 - } + /** + * 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 + } - // Handle `Response.error()` instances. - if (isResponseError(response)) { - socket.destroy(new TypeError('Network error')) - return - } + // Handle `Response.error()` instances. + if (isResponseError(response)) { + socket.destroy(new TypeError('Network error')) + return + } - // Establish a mocked socket connection. - // Prior to this point, the socket has been pending. - mockConnect(socket, connectionOptions) + // Establish a mocked socket connection. + // Prior to this point, the socket has been pending. + this.mockConnect(socket, connectionOptions) - /** - * @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' - ) + /** + * @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' + ) - // Construct a regular server response to delegate body parsing to Node.js. - const serverResponse = new ServerResponse(new IncomingMessage(socket)) + // 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 + ) - 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). + * @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 */ - new Writable({ - write(chunk, encoding, callback) { - socket.push(chunk, encoding) - callback?.() - }, - }) as net.Socket - ) + 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 + ) - /** - * @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 - } + if (response.body) { + try { + const reader = response.body.getReader() - serverResponse.write(value) - } - } catch (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() + while (true) { + const { done, value } = await reader.read() + + if (done) { + serverResponse.end() + break + } + + serverResponse.write(value) + } + } catch (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() } - } 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) + // 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) + } } } From 4f8cfe4635bc91d5cddcfa340ac9919babb3c1dd Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 7 Oct 2025 13:59:18 +0200 Subject: [PATCH 44/45] chore(wip): continue --- src/interceptors/http/index.ts | 95 +++++-- src/interceptors/net/index.ts | 162 +++++++---- src/interceptors/net/mock-socket.ts | 267 +++++++++++++----- src/interceptors/net/socket-recorder.ts | 8 +- .../utils/normalize-tls-connect-args.test.ts | 41 +++ .../http/compliance/http-ssl-socket.test.ts | 37 ++- test/modules/http/response/http-https.test.ts | 40 ++- tsconfig.json | 2 +- 8 files changed, 466 insertions(+), 186 deletions(-) create mode 100644 src/interceptors/net/utils/normalize-tls-connect-args.test.ts diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index e27a8673b..9871c2815 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -44,11 +44,15 @@ export class HttpRequestInterceptor extends Interceptor { restoreHeadersPrototype() }) - socketInterceptor.on('connection', ({ options, socket }) => { - socket.runInternally(() => { - socket.once('write', (chunk, encoding) => { - const firstFrame = chunk.toString() + socketInterceptor.on( + 'connection', + ({ options, socket, controller: socketController }) => { + socketController.once('write', (chunk, encoding) => { + if (!chunk) { + return + } + const firstFrame = chunk.toString() if (!firstFrame.includes('HTTP/')) { return } @@ -74,12 +78,12 @@ export class HttpRequestInterceptor extends Interceptor { }, onRequest: async (request) => { const requestId = createRequestId() - const controller = new RequestController(request) + const requestController = new RequestController(request) const isRequestHandled = await handleRequest({ request, requestId, - controller, + controller: requestController, emitter: this.emitter, onResponse: async (response) => { if (this.emitter.listenerCount('response') > 0) { @@ -102,6 +106,8 @@ export class HttpRequestInterceptor extends Interceptor { }) }, onRequestError: async (response) => { + socketController.free() + await this.respondWith({ socket, connectionOptions: options, @@ -117,7 +123,7 @@ export class HttpRequestInterceptor extends Interceptor { }) if (!isRequestHandled) { - const passthroughSocket = socket.passthrough() + const passthroughSocket = socketController.passthrough() /** * @note Creating a passthrough socket does NOT trigger the "onSocket" callback @@ -166,17 +172,20 @@ export class HttpRequestInterceptor extends Interceptor { * to wait for the HTTP message while the parser cannot write that message * because the stream is paused! */ - socket - .on('write', (chunk, encoding) => { - if (chunk) { - requestParser.execute(toBuffer(chunk, encoding)) - } - }) - .on('finish', () => requestParser.free()) - .on('error', () => requestParser.free()) + socketController.on('write', (chunk, encoding) => { + if (chunk) { + requestParser.execute(toBuffer(chunk, encoding)) + } + }) + + socketController.runInternally((socket) => { + socket + .on('finish', () => requestParser.free()) + .on('error', () => requestParser.free()) + }) }) - }) - }) + } + ) } /** @@ -210,14 +219,21 @@ export class HttpRequestInterceptor extends Interceptor { socket.emit('connect') socket.emit('ready') - if (connectionOptions.protocol === 'https:') { - socket.emit('secure') - socket.emit('secureConnect') - socket.emit( - 'session', - connectionOptions.session || Buffer.from('mock-session-renegotiate') - ) - socket.emit('session', Buffer.from('mock-session-resume')) + 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. } } @@ -238,16 +254,21 @@ export class HttpRequestInterceptor extends Interceptor { 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 } - // Establish a mocked socket connection. - // Prior to this point, the socket has been pending. - this.mockConnect(socket, connectionOptions) - /** * @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 @@ -257,9 +278,11 @@ export class HttpRequestInterceptor extends Interceptor { '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 @@ -274,6 +297,16 @@ export class HttpRequestInterceptor extends Interceptor { }) 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 @@ -314,6 +347,8 @@ export class HttpRequestInterceptor extends Interceptor { serverResponse.write(value) } } catch (error) { + console.log('response stream error:', error) + if (error instanceof Error) { /** * Destroy the socket if the response stream errored. diff --git a/src/interceptors/net/index.ts b/src/interceptors/net/index.ts index 1d980defe..5f6ea3710 100644 --- a/src/interceptors/net/index.ts +++ b/src/interceptors/net/index.ts @@ -1,18 +1,28 @@ import net from 'node:net' import tls from 'node:tls' import { Interceptor } from '../../Interceptor' -import { MockSocket } from './mock-socket' +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 } ] } @@ -84,69 +94,119 @@ export class SocketInterceptor extends Interceptor { } protected setup(): void { - const originalNetConnect = Reflect.get(net.connect, kOriginalValue) + 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: Array) => { - const [options, connectionCallback] = normalizeNetConnectArgs( - args as NetConnectArgs - ) - - const socket = new MockSocket({ - ...args, - connectionCallback, - 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, + Reflect.set( + net.connect, + kImplementation, + (...args: [any, any]): net.Socket => { + const [options, connectionCallback] = normalizeNetConnectArgs( + args as NetConnectArgs + ) + + const socket = new MockSocket({ + ...args, + connectionCallback, }) - }) - return socket - }) + 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 - const originalTlsConnect = Reflect.get(tls.connect, kOriginalValue) this.subscriptions.push(() => { Reflect.set(tls.connect, kImplementation, originalTlsConnect) }) - Reflect.set(tls.connect, kImplementation, (...args: Array) => { - const [options, connectionCallback] = normalizeNetConnectArgs( - args as NetConnectArgs - ) - options.secure = true - - const socket = new MockSocket({ - ...args, - connectionCallback, - createConnection() { - return originalTlsConnect(...args) - }, - }) - - process.nextTick(() => { - this.emitter.emit('connection', { - options, + 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) + }, }) - }) - return socket - }) + 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 index 4ffd409a8..0df9dd149 100644 --- a/src/interceptors/net/mock-socket.ts +++ b/src/interceptors/net/mock-socket.ts @@ -1,4 +1,7 @@ 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, @@ -7,37 +10,93 @@ import { createSocketRecorder, type SocketRecorder } from './socket-recorder' interface MockSocketConstructorOptions extends net.SocketConstructorOpts { secure?: boolean - createConnection: () => net.Socket connectionCallback?: () => void } -const kRecorder = Symbol('kRecorder') -const kPassthroughSocket = Symbol('kPassthroughSocket') - /** * 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; - - [kRecorder]: SocketRecorder; - [kPassthroughSocket]?: net.Socket + public connecting: boolean constructor(protected readonly options: MockSocketConstructorOptions) { super(options) - this.connecting = false + this.connecting = true - this.once('connect', () => { + 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.options?.connectionCallback?.() + this.emit('connect') }) - this._final = (callback) => callback(null) + return this + } +} + +// +// +// - this[kRecorder] = createSocketRecorder(this, { - onEntry: (entry) => { +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) @@ -45,84 +104,142 @@ export class MockSocket extends net.Socket { 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] - } + /** + * @todo Finish this for passthrough. + */ + throw new Error('Tests fail because of this') }, }) - return this[kRecorder].socket + this.socket = this.#recorder.socket } - public connect() { - this.connecting = true + 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`? + */ - queueMicrotask(() => { - this.emit('connect') - }) + try { + this.#recorder.pause() + callback(this.socket) + } finally { + this.#recorder.resume() + } + } - return this + public passthrough(): SocketType { + const socket = this.options.createConnection() + this.#recorder.replay(socket) + this.#passthroughSocket = socket + return socket } - public write(...args: any): boolean { - const [chunk, encoding, callback] = normalizeSocketWriteArgs( - args as WriteArgs - ) - this.runInternally(() => { - this.emit('write', chunk, encoding, callback) - }) - return true + public free(): void { + this.#recorder.pause() + this.#recorder.free() + + let disposeCallback: (() => void) | undefined + while ((disposeCallback = this.#subscriptions.shift())) { + disposeCallback?.() + } } +} - public push(chunk: any, encoding?: BufferEncoding): boolean { - this.runInternally(() => { - this.emit('push', chunk, encoding) - }) - return super.push(chunk, encoding) +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 - public end(...args: any) { - const [chunk, encoding, callback] = normalizeSocketWriteArgs( - args as WriteArgs - ) - this.runInternally(() => { - this.emit('write', chunk, encoding, callback) + 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() + }) }) - return super.end.apply(this, args) - } - /** - * Invokes the given callback without its actions being recorded. - * Use this for internal logic that must not be replayed on the passthrough socket. - */ - public runInternally(callback: () => void) { - try { - this[kRecorder].pause() - callback() - } finally { - this[kRecorder].resume() + this.once('secure', this.mockSecureConnect.bind(this)) + + if (secureConnectionListener) { + this.once('secureConnect', secureConnectionListener) } + + process.nextTick(() => { + underlyingSocket.mockConnect() + }) } - /** - * Establishes the actual connection behind this socket. - * Replays all the consumer interaction on the passthrough socket - * and mirrors all the subsequent mock socket interactions onto the passthrough socket. - */ - public passthrough(): net.Socket { - const socket = this.options.createConnection() - this[kRecorder].replay(socket) - this[kPassthroughSocket] = socket - return socket + 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.ts b/src/interceptors/net/socket-recorder.ts index 746769783..15cea6a9c 100644 --- a/src/interceptors/net/socket-recorder.ts +++ b/src/interceptors/net/socket-recorder.ts @@ -7,6 +7,7 @@ export interface SocketRecorder { replay: (newSocket: net.Socket) => void pause: () => void resume: () => void + free: () => void } export interface SocketRecorderEntry { @@ -74,7 +75,9 @@ export function createSocketRecorder( ) { return new Proxy(target[property as keyof T] as Function, { apply(fn, thisArg, args) { - const defaultApply = () => fn.apply(thisArg, args) + const defaultApply = () => { + return fn.apply(thisArg, args) + } if (fn.name === 'destroy') { entries.length = 0 @@ -147,6 +150,9 @@ export function createSocketRecorder( } entries.length = 0 }, + free() { + entries.length = 0 + }, pause() { isPaused = true }, 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/http-ssl-socket.test.ts b/test/modules/http/compliance/http-ssl-socket.test.ts index dcb01f17f..0c3138433 100644 --- a/test/modules/http/compliance/http-ssl-socket.test.ts +++ b/test/modules/http/compliance/http-ssl-socket.test.ts @@ -1,7 +1,7 @@ // @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' const interceptor = new HttpRequestInterceptor() @@ -20,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') @@ -29,15 +29,15 @@ it('emits a correct TLS Socket instance for a handled HTTPS request', async () = const socket = await socketPromise + expect.soft(socket).toBeInstanceOf(TLSSocket) + // 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(socket.getSession()).toBeUndefined() - expect(socket.getProtocol()).toBe('TLSv1.3') - expect(socket.isSessionReused()).toBe(false) + 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) }) it('emits a correct TLS Socket instance for a bypassed HTTPS request', async () => { @@ -47,13 +47,12 @@ 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(socket.getSession()).toBeUndefined() - expect(socket.getProtocol()).toBe('TLSv1.3') - expect(socket.isSessionReused()).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) }) diff --git a/test/modules/http/response/http-https.test.ts b/test/modules/http/response/http-https.test.ts index 01df51a17..c29fae426 100644 --- a/test/modules/http/response/http-https.test.ts +++ b/test/modules/http/response/http-https.test.ts @@ -2,14 +2,14 @@ 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 { 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') }) }) @@ -36,8 +36,8 @@ interceptor.on('request', ({ request, controller }) => { }) beforeAll(async () => { - await httpServer.listen() interceptor.apply() + await httpServer.listen() }) afterAll(async () => { @@ -73,7 +73,7 @@ it('responds to a handled request issued by "https.get"', async () => { await expect(text()).resolves.toEqual('mocked') }) -it('bypasses an unhandled request issued by "http.get"', async () => { +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) @@ -84,7 +84,7 @@ it('bypasses an unhandled request issued by "http.get"', async () => { await expect(text()).resolves.toEqual('/get') }) -it('bypasses an unhandled request issued by "https.get"', async () => { +it.only('bypasses an unhandled request issued by "https.get"', async () => { const request = https.get(httpServer.https.url('/get'), { rejectUnauthorized: false, }) @@ -109,19 +109,41 @@ it('responds to a handled request issued by "http.request"', async () => { }) it('responds to a handled request issued by "https.request"', async () => { - const request = https.request('https://any.thing/non-existing') + 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(res).toMatchObject>({ + expect.soft(res).toMatchObject>({ statusCode: 301, statusMessage: 'Moved Permanently', headers: { 'content-type': 'text/plain', }, }) - await expect(text()).resolves.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 () => { diff --git a/tsconfig.json b/tsconfig.json index 2bd2485ee..3c06b1822 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "removeComments": false, "esModuleInterop": true, "downlevelIteration": true, - "lib": ["dom", "dom.iterable", "ES2018.AsyncGenerator"], + "lib": ["dom", "dom.iterable", "ES2022.Error", "ES2018.AsyncGenerator"], "types": ["@types/node", "vitest/globals"], "baseUrl": ".", "paths": { From bc85d60f18a6a3800cbc427049bbdf63660b1da9 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 14 Nov 2025 10:22:06 +0100 Subject: [PATCH 45/45] chore: migrate to the new `RequestController` API --- src/interceptors/http/index.ts | 107 +++++++++++++-------------------- 1 file changed, 43 insertions(+), 64 deletions(-) diff --git a/src/interceptors/http/index.ts b/src/interceptors/http/index.ts index 9871c2815..b0e49c91a 100644 --- a/src/interceptors/http/index.ts +++ b/src/interceptors/http/index.ts @@ -9,7 +9,6 @@ import type { NetworkConnectionOptions } from '../net/utils/normalize-net-connec import { toBuffer } from './utils/to-buffer' import { createRequestId } from '../../createRequestId' import { RequestController } from '../../RequestController' -import { handleRequest } from '../../utils/handleRequest' import { getRawFetchHeaders, recordRawFetchHeaders, @@ -35,14 +34,10 @@ export class HttpRequestInterceptor extends Interceptor { public setup() { socketInterceptor.apply() - this.subscriptions.push(() => { - socketInterceptor.dispose() - }) + this.subscriptions.push(() => socketInterceptor.dispose()) recordRawFetchHeaders() - this.subscriptions.push(() => { - restoreHeadersPrototype() - }) + this.subscriptions.push(() => restoreHeadersPrototype()) socketInterceptor.on( 'connection', @@ -78,14 +73,9 @@ export class HttpRequestInterceptor extends Interceptor { }, onRequest: async (request) => { const requestId = createRequestId() - const requestController = new RequestController(request) - - const isRequestHandled = await handleRequest({ - request, - requestId, - controller: requestController, - emitter: this.emitter, - onResponse: async (response) => { + + const requestController = new RequestController(request, { + respondWith: async (response) => { if (this.emitter.listenerCount('response') > 0) { const responseClone = response.clone() process.nextTick(() => { @@ -105,59 +95,48 @@ export class HttpRequestInterceptor extends Interceptor { response, }) }, - onRequestError: async (response) => { - socketController.free() - - await this.respondWith({ - socket, - connectionOptions: options, - request, - response, - }) + errorWith: (reason) => { + if (reason instanceof Error) { + socket.destroy(reason) + } }, - onError(error) { - if (error instanceof Error) { - socket.destroy(error) + 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()) } }, }) - - if (!isRequestHandled) { - 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()) - } - } }, })